Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/span-details-post-search-scope.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1564,7 +1567,7 @@ export interface operations {
};
};
};
getSpanDetailsForTrace: {
querySpanDetailsForTrace: {
parameters: {
query?: never;
header?: never;
Expand All @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
18 changes: 16 additions & 2 deletions plugins/openchoreo-observability/src/api/ObservabilityApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ export interface ObservabilityApi {
traceId: string,
spanId: string,
namespaceName: string,
projectName: string,
environmentName: string,
componentName?: string,
): Promise<SpanDetails>;

getRCAReports(
Expand Down Expand Up @@ -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,
Expand All @@ -422,7 +424,9 @@ export class ObservabilityClient implements ObservabilityApi {
traceId: string,
spanId: string,
namespaceName: string,
projectName: string,
environmentName: string,
componentName?: string,
): Promise<SpanDetails> {
const { observerUrl } = await this.urlCache.resolveUrls(
namespaceName,
Expand All @@ -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,
},
}),
},
);

Expand All @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ const ObservabilityTracesContent = () => {

const spanDetails = useSpanDetails({
namespaceName: namespace,
projectName,
environmentName: filters.environment?.name ?? '',
componentName,
});

const handleFiltersChange = useCallback(
Expand Down
33 changes: 27 additions & 6 deletions plugins/openchoreo-observability/src/hooks/useSpanDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { SpanDetails } from '../types';

interface UseSpanDetailsOptions {
namespaceName: string;
projectName: string;
environmentName: string;
componentName?: string;
}

export function useSpanDetails(options: UseSpanDetailsOptions) {
Expand All @@ -16,8 +18,25 @@ export function useSpanDetails(options: UseSpanDetailsOptions) {
const [loadingMap, setLoadingMap] = useState<Map<string, boolean>>(new Map());
const [errorMap, setErrorMap] = useState<Map<string, string>>(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) => {
Expand All @@ -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));
Expand All @@ -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 {
Expand Down
Loading