diff --git a/.changeset/span-details-post-search-scope.md b/.changeset/span-details-post-search-scope.md new file mode 100644 index 000000000..14bcad992 --- /dev/null +++ b/.changeset/span-details-post-search-scope.md @@ -0,0 +1,5 @@ +--- +'@openchoreo/backstage-plugin-openchoreo-observability': patch +--- + +Fetch span details via the new `POST /traces/{traceId}/spans/{spanId}` endpoint with `searchScope` in the body, and read span `status` from its `code` field so expanding a trace no longer crashes. diff --git a/packages/openchoreo-client-node/openapi/openchoreo-observability-api.yaml b/packages/openchoreo-client-node/openapi/openchoreo-observability-api.yaml index 0d9abdbc7..bcaba3423 100644 --- a/packages/openchoreo-client-node/openapi/openchoreo-observability-api.yaml +++ b/packages/openchoreo-client-node/openapi/openchoreo-observability-api.yaml @@ -318,12 +318,12 @@ paths: $ref: '#/components/schemas/ErrorResponse' /api/v1alpha1/traces/{traceId}/spans/{spanId}: - get: + post: tags: - Traces summary: Get details of a span for a trace description: Get details of a span for a trace from the observer service - operationId: getSpanDetailsForTrace + operationId: querySpanDetailsForTrace parameters: - name: traceId in: path @@ -337,6 +337,12 @@ paths: description: The ID of the span schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TraceSpanDetailsRequest' responses: '200': description: Span details queried successfully @@ -1299,6 +1305,14 @@ components: type: integer description: The time taken to query the spans in milliseconds + # Request schema for span details + TraceSpanDetailsRequest: + type: object + properties: + searchScope: + $ref: '#/components/schemas/ComponentSearchScope' + required: [searchScope] + TraceSpanDetailsResponse: type: object properties: diff --git a/packages/openchoreo-client-node/src/generated/observability/types.ts b/packages/openchoreo-client-node/src/generated/observability/types.ts index d3becfc2f..c64ed3316 100644 --- a/packages/openchoreo-client-node/src/generated/observability/types.ts +++ b/packages/openchoreo-client-node/src/generated/observability/types.ts @@ -135,13 +135,13 @@ export interface paths { path?: never; cookie?: never; }; + get?: never; + put?: never; /** * Get details of a span for a trace * @description Get details of a span for a trace from the observer service */ - get: operations['getSpanDetailsForTrace']; - put?: never; - post?: never; + post: operations['querySpanDetailsForTrace']; delete?: never; options?: never; head?: never; @@ -714,6 +714,9 @@ export interface components { /** @description The time taken to query the spans in milliseconds */ tookMs?: number; }; + TraceSpanDetailsRequest: { + searchScope: components['schemas']['ComponentSearchScope']; + }; TraceSpanDetailsResponse: { /** @description The span ID */ spanId?: string; @@ -1564,7 +1567,7 @@ export interface operations { }; }; }; - getSpanDetailsForTrace: { + querySpanDetailsForTrace: { parameters: { query?: never; header?: never; @@ -1576,7 +1579,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['TraceSpanDetailsRequest']; + }; + }; responses: { /** @description Span details queried successfully */ 200: { diff --git a/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts index 654e38167..1bf4c4bfd 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts @@ -314,3 +314,88 @@ describe('ObservabilityClient.getFinOpsReport', () => { expect(url).toContain('r1%2Fspecial'); }); }); + +describe('ObservabilityClient.getTraceSpans', () => { + beforeEach(() => { + jest.clearAllMocks(); + resolveUrls.mockResolvedValue({ observerUrl: 'http://observer' }); + }); + + it('passes the OTel status object through unchanged', async () => { + mockFetchApi.fetch.mockResolvedValueOnce( + mockOkResponse({ + spans: [ + { + spanId: 'span-1', + spanName: 'root', + startTime: 's', + endTime: 'e', + durationNs: 100, + status: { code: 'error', message: 'boom' }, + }, + ], + total: 1, + tookMs: 3, + }), + ); + + const client = createClient(); + const result = await client.getTraceSpans( + 'trace-1', + 'ns1', + 'project-a', + 'dev', + 'component-a', + ); + + const [url] = mockFetchApi.fetch.mock.calls[0]; + expect(url).toBe('http://observer/api/v1alpha1/traces/trace-1/spans/query'); + expect(result.spans[0].status).toBe('error'); + }); +}); + +describe('ObservabilityClient.getSpanDetails', () => { + beforeEach(() => { + jest.clearAllMocks(); + resolveUrls.mockResolvedValue({ observerUrl: 'http://observer' }); + }); + + it('maps the status code and posts the search scope', async () => { + mockFetchApi.fetch.mockResolvedValueOnce( + mockOkResponse({ + spanId: 'span-1', + spanName: 'GET /api', + startTime: 's', + endTime: 'e', + durationNs: 100, + status: { code: 'ok' }, + attributes: [{ key: 'http.method', value: 'GET' }], + }), + ); + + const client = createClient(); + const result = await client.getSpanDetails( + 'trace-1', + 'span-1', + 'ns1', + 'proj1', + 'dev', + 'comp1', + ); + + const [url, init] = mockFetchApi.fetch.mock.calls[0]; + expect(url).toBe( + 'http://observer/api/v1alpha1/traces/trace-1/spans/span-1', + ); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ + searchScope: { + namespace: 'ns1', + project: 'proj1', + component: 'comp1', + environment: 'dev', + }, + }); + expect(result.status).toBe('ok'); + }); +}); diff --git a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts index 3b6863db2..8cd60e21c 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts @@ -86,7 +86,9 @@ export interface ObservabilityApi { traceId: string, spanId: string, namespaceName: string, + projectName: string, environmentName: string, + componentName?: string, ): Promise; getRCAReports( @@ -411,7 +413,7 @@ export class ObservabilityClient implements ObservabilityApi { endTime: s.endTime ?? '', durationNs: s.durationNs ?? 0, parentSpanId: s.parentSpanId, - status: s.status, + status: s.status?.code, })), total: data.total ?? 0, tookMs: data.tookMs ?? 0, @@ -422,7 +424,9 @@ export class ObservabilityClient implements ObservabilityApi { traceId: string, spanId: string, namespaceName: string, + projectName: string, environmentName: string, + componentName?: string, ): Promise { const { observerUrl } = await this.urlCache.resolveUrls( namespaceName, @@ -434,7 +438,16 @@ export class ObservabilityClient implements ObservabilityApi { traceId, )}/spans/${encodeURIComponent(spanId)}`, { - headers: { ...DIRECT_HEADER }, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...DIRECT_HEADER }, + body: JSON.stringify({ + searchScope: { + namespace: namespaceName, + project: projectName, + ...(componentName ? { component: componentName } : {}), + environment: environmentName, + }, + }), }, ); @@ -455,6 +468,7 @@ export class ObservabilityClient implements ObservabilityApi { endTime: data.endTime ?? '', durationNs: data.durationNs ?? 0, parentSpanId: data.parentSpanId, + status: data.status?.code, attributes: data.attributes, resourceAttributes: data.resourceAttributes, }; diff --git a/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx b/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx index d170c56ba..fe0add0a5 100644 --- a/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx +++ b/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx @@ -93,7 +93,9 @@ const ObservabilityTracesContent = () => { const spanDetails = useSpanDetails({ namespaceName: namespace, + projectName, environmentName: filters.environment?.name ?? '', + componentName, }); const handleFiltersChange = useCallback( diff --git a/plugins/openchoreo-observability/src/hooks/useSpanDetails.ts b/plugins/openchoreo-observability/src/hooks/useSpanDetails.ts index 7ef4ffb68..d7d2250e2 100644 --- a/plugins/openchoreo-observability/src/hooks/useSpanDetails.ts +++ b/plugins/openchoreo-observability/src/hooks/useSpanDetails.ts @@ -5,7 +5,9 @@ import { SpanDetails } from '../types'; interface UseSpanDetailsOptions { namespaceName: string; + projectName: string; environmentName: string; + componentName?: string; } export function useSpanDetails(options: UseSpanDetailsOptions) { @@ -16,8 +18,25 @@ export function useSpanDetails(options: UseSpanDetailsOptions) { const [loadingMap, setLoadingMap] = useState>(new Map()); const [errorMap, setErrorMap] = useState>(new Map()); - // Composite key for deduplication - const makeKey = (traceId: string, spanId: string) => `${traceId}::${spanId}`; + // Composite key for deduplication. Scoped so switching components can't + // reuse another scope's pending/error/details state. + const makeKey = useCallback( + (traceId: string, spanId: string) => + [ + options.namespaceName, + options.projectName, + options.environmentName, + options.componentName ?? '', + traceId, + spanId, + ].join('::'), + [ + options.namespaceName, + options.projectName, + options.environmentName, + options.componentName, + ], + ); const fetchSpanDetails = useCallback( async (traceId: string, spanId: string) => { @@ -39,7 +58,9 @@ export function useSpanDetails(options: UseSpanDetailsOptions) { traceId, spanId, options.namespaceName, + options.projectName, options.environmentName, + options.componentName, ); setDetailsMap(prev => new Map(prev).set(key, result)); @@ -58,25 +79,25 @@ export function useSpanDetails(options: UseSpanDetailsOptions) { }); } }, - [observabilityApi, options, loadingMap, detailsMap], + [observabilityApi, options, loadingMap, detailsMap, makeKey], ); const getDetails = useCallback( (traceId: string, spanId: string): SpanDetails | undefined => detailsMap.get(makeKey(traceId, spanId)), - [detailsMap], + [detailsMap, makeKey], ); const isLoading = useCallback( (traceId: string, spanId: string): boolean => loadingMap.get(makeKey(traceId, spanId)) ?? false, - [loadingMap], + [loadingMap, makeKey], ); const getError = useCallback( (traceId: string, spanId: string): string | undefined => errorMap.get(makeKey(traceId, spanId)), - [errorMap], + [errorMap, makeKey], ); return {