autotel-sentry 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -0
- package/dist/index.cjs +330 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +88 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +325 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/helpers.test.ts +143 -0
- package/src/helpers.ts +256 -0
- package/src/index.ts +7 -0
- package/src/processor.test.ts +195 -0
- package/src/processor.ts +191 -0
- package/src/propagator.test.ts +68 -0
- package/src/propagator.ts +96 -0
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for mapping OpenTelemetry spans to Sentry transactions/spans and context.
|
|
3
|
+
* Aligned with Sentry's OpenTelemetry integration spec.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
7
|
+
import {
|
|
8
|
+
SEMATTRS_HTTP_URL,
|
|
9
|
+
SEMATTRS_HTTP_STATUS_CODE,
|
|
10
|
+
SEMATTRS_RPC_GRPC_STATUS_CODE,
|
|
11
|
+
SEMATTRS_EXCEPTION_MESSAGE,
|
|
12
|
+
SEMATTRS_EXCEPTION_STACKTRACE,
|
|
13
|
+
SEMATTRS_EXCEPTION_TYPE,
|
|
14
|
+
} from '@opentelemetry/semantic-conventions';
|
|
15
|
+
|
|
16
|
+
/** Sentry span status strings per their spec (maps to gRPC-style codes). */
|
|
17
|
+
export type SentrySpanStatus =
|
|
18
|
+
| 'ok'
|
|
19
|
+
| 'cancelled'
|
|
20
|
+
| 'unknown_error'
|
|
21
|
+
| 'invalid_argument'
|
|
22
|
+
| 'deadline_exceeded'
|
|
23
|
+
| 'not_found'
|
|
24
|
+
| 'already_exists'
|
|
25
|
+
| 'permission_denied'
|
|
26
|
+
| 'resource_exhausted'
|
|
27
|
+
| 'failed_precondition'
|
|
28
|
+
| 'aborted'
|
|
29
|
+
| 'out_of_range'
|
|
30
|
+
| 'unimplemented'
|
|
31
|
+
| 'internal_error'
|
|
32
|
+
| 'unavailable'
|
|
33
|
+
| 'data_loss'
|
|
34
|
+
| 'unauthenticated';
|
|
35
|
+
|
|
36
|
+
export interface ParsedSpanDescription {
|
|
37
|
+
op: string;
|
|
38
|
+
description: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** OTel timestamps are typically in nanoseconds (HrTime or number). */
|
|
42
|
+
export function convertOtelTimeToSeconds(time: number | [number, number]): number {
|
|
43
|
+
if (Array.isArray(time)) {
|
|
44
|
+
return time[0] + time[1] / 1e9;
|
|
45
|
+
}
|
|
46
|
+
return time / 1e9;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CANONICAL_HTTP_MAP: Record<string, SentrySpanStatus> = {
|
|
50
|
+
'400': 'failed_precondition',
|
|
51
|
+
'401': 'unauthenticated',
|
|
52
|
+
'403': 'permission_denied',
|
|
53
|
+
'404': 'not_found',
|
|
54
|
+
'409': 'aborted',
|
|
55
|
+
'429': 'resource_exhausted',
|
|
56
|
+
'499': 'cancelled',
|
|
57
|
+
'500': 'internal_error',
|
|
58
|
+
'501': 'unimplemented',
|
|
59
|
+
'503': 'unavailable',
|
|
60
|
+
'504': 'deadline_exceeded',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const CANONICAL_GRPC_MAP: Record<string, SentrySpanStatus> = {
|
|
64
|
+
'1': 'cancelled',
|
|
65
|
+
'2': 'unknown_error',
|
|
66
|
+
'3': 'invalid_argument',
|
|
67
|
+
'4': 'deadline_exceeded',
|
|
68
|
+
'5': 'not_found',
|
|
69
|
+
'6': 'already_exists',
|
|
70
|
+
'7': 'permission_denied',
|
|
71
|
+
'8': 'resource_exhausted',
|
|
72
|
+
'9': 'failed_precondition',
|
|
73
|
+
'10': 'aborted',
|
|
74
|
+
'11': 'out_of_range',
|
|
75
|
+
'12': 'unimplemented',
|
|
76
|
+
'13': 'internal_error',
|
|
77
|
+
'14': 'unavailable',
|
|
78
|
+
'15': 'data_loss',
|
|
79
|
+
'16': 'unauthenticated',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function mapOtelStatus(otelSpan: ReadableSpan): SentrySpanStatus {
|
|
83
|
+
const { status, attributes } = otelSpan;
|
|
84
|
+
const code = status.code;
|
|
85
|
+
|
|
86
|
+
if (code !== undefined && (code < 0 || code > 2)) {
|
|
87
|
+
return 'unknown_error';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (code === 0 || code === 1) {
|
|
91
|
+
return 'ok';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const httpCode = attributes[SEMATTRS_HTTP_STATUS_CODE];
|
|
95
|
+
const grpcCode = attributes[SEMATTRS_RPC_GRPC_STATUS_CODE];
|
|
96
|
+
|
|
97
|
+
if (typeof httpCode === 'string') {
|
|
98
|
+
const sentryStatus = CANONICAL_HTTP_MAP[httpCode];
|
|
99
|
+
if (sentryStatus) return sentryStatus;
|
|
100
|
+
}
|
|
101
|
+
if (typeof httpCode === 'number') {
|
|
102
|
+
const sentryStatus = CANONICAL_HTTP_MAP[String(httpCode)];
|
|
103
|
+
if (sentryStatus) return sentryStatus;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof grpcCode === 'string') {
|
|
107
|
+
const sentryStatus = CANONICAL_GRPC_MAP[grpcCode];
|
|
108
|
+
if (sentryStatus) return sentryStatus;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return 'unknown_error';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Derive Sentry op and description from OTel span name, kind, and attributes. */
|
|
115
|
+
export function parseSpanDescription(otelSpan: ReadableSpan): ParsedSpanDescription {
|
|
116
|
+
const { name, kind, attributes } = otelSpan;
|
|
117
|
+
const description = name || 'unknown';
|
|
118
|
+
|
|
119
|
+
const spanKind = kind ?? 0;
|
|
120
|
+
const isHttp = spanKind === 2 || attributes['http.method'] != null;
|
|
121
|
+
const isDb =
|
|
122
|
+
attributes['db.system'] != null ||
|
|
123
|
+
attributes['db.operation'] != null ||
|
|
124
|
+
attributes['db.statement'] != null;
|
|
125
|
+
|
|
126
|
+
let op = 'default';
|
|
127
|
+
if (isHttp) {
|
|
128
|
+
op = 'http.client';
|
|
129
|
+
const method = attributes['http.method'];
|
|
130
|
+
const route = attributes['http.route'] ?? attributes['http.target'];
|
|
131
|
+
if (method && route) op = 'http.server';
|
|
132
|
+
} else if (isDb) {
|
|
133
|
+
op = 'db.query';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { op, description };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface TraceData {
|
|
140
|
+
traceId: string;
|
|
141
|
+
spanId: string;
|
|
142
|
+
parentSpanId?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getTraceData(otelSpan: ReadableSpan): TraceData {
|
|
146
|
+
const ctx = otelSpan.spanContext();
|
|
147
|
+
return {
|
|
148
|
+
traceId: ctx.traceId,
|
|
149
|
+
spanId: ctx.spanId,
|
|
150
|
+
parentSpanId: otelSpan.parentSpanContext?.spanId,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Check if the span represents a request to Sentry (ingestion); such spans must not be sent to Sentry. */
|
|
155
|
+
export function isSentryRequestSpan(
|
|
156
|
+
otelSpan: ReadableSpan,
|
|
157
|
+
getDsnHost: () => string | undefined,
|
|
158
|
+
): boolean {
|
|
159
|
+
const httpUrl = otelSpan.attributes[SEMATTRS_HTTP_URL];
|
|
160
|
+
if (httpUrl == null) return false;
|
|
161
|
+
const url = typeof httpUrl === 'string' ? httpUrl : String(httpUrl);
|
|
162
|
+
const host = getDsnHost();
|
|
163
|
+
if (!host) return false;
|
|
164
|
+
return url.includes(host);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Attributes and resource for Sentry's otel context. */
|
|
168
|
+
export function getOtelContextFromSpan(otelSpan: ReadableSpan): {
|
|
169
|
+
attributes: Record<string, unknown>;
|
|
170
|
+
resource: Record<string, unknown>;
|
|
171
|
+
} {
|
|
172
|
+
const attributes: Record<string, unknown> = {};
|
|
173
|
+
for (const [k, v] of Object.entries(otelSpan.attributes)) {
|
|
174
|
+
attributes[k] = v;
|
|
175
|
+
}
|
|
176
|
+
const resource: Record<string, unknown> = {};
|
|
177
|
+
const res = otelSpan.resource;
|
|
178
|
+
if (res && res.attributes) {
|
|
179
|
+
for (const [k, v] of Object.entries(res.attributes)) {
|
|
180
|
+
resource[k] = v;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { attributes, resource };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Update a Sentry span with OTel span data (status, op, description, data). */
|
|
187
|
+
export function updateSpanWithOtelData(
|
|
188
|
+
sentrySpan: { setStatus: (s: { status?: string }) => void; setData: (k: string, v: unknown) => void; op?: string; description?: string },
|
|
189
|
+
otelSpan: ReadableSpan,
|
|
190
|
+
): void {
|
|
191
|
+
const status = mapOtelStatus(otelSpan);
|
|
192
|
+
sentrySpan.setStatus({ status });
|
|
193
|
+
sentrySpan.setData('otel.kind', otelSpan.kind ?? 0);
|
|
194
|
+
for (const [key, value] of Object.entries(otelSpan.attributes)) {
|
|
195
|
+
sentrySpan.setData(key, value);
|
|
196
|
+
}
|
|
197
|
+
const { op, description } = parseSpanDescription(otelSpan);
|
|
198
|
+
sentrySpan.op = op;
|
|
199
|
+
sentrySpan.description = description;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Update a Sentry transaction with OTel span data. */
|
|
203
|
+
export function updateTransactionWithOtelData(
|
|
204
|
+
transaction: { setStatus: (s: { status?: string }) => void; op?: string; name?: string },
|
|
205
|
+
otelSpan: ReadableSpan,
|
|
206
|
+
): void {
|
|
207
|
+
const status = mapOtelStatus(otelSpan);
|
|
208
|
+
transaction.setStatus({ status });
|
|
209
|
+
const { op, description } = parseSpanDescription(otelSpan);
|
|
210
|
+
transaction.op = op;
|
|
211
|
+
transaction.name = description;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Set otel context on transaction and finish it. */
|
|
215
|
+
export function finishTransactionWithContextFromOtelData(
|
|
216
|
+
transaction: {
|
|
217
|
+
setContext: (name: string, ctx: { attributes?: Record<string, unknown>; resource?: Record<string, unknown> }) => void;
|
|
218
|
+
finish: (endTime?: number) => void;
|
|
219
|
+
},
|
|
220
|
+
otelSpan: ReadableSpan,
|
|
221
|
+
): void {
|
|
222
|
+
const { attributes, resource } = getOtelContextFromSpan(otelSpan);
|
|
223
|
+
transaction.setContext('otel', { attributes, resource });
|
|
224
|
+
transaction.finish(convertOtelTimeToSeconds(otelSpan.endTime));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Build synthetic Error from OTel exception event attributes and capture with Sentry. */
|
|
228
|
+
export function generateSentryErrorsFromOtelSpan(
|
|
229
|
+
otelSpan: ReadableSpan,
|
|
230
|
+
captureException: (error: Error, options?: { contexts?: Record<string, unknown> }) => void,
|
|
231
|
+
): void {
|
|
232
|
+
const events = otelSpan.events ?? [];
|
|
233
|
+
for (const event of events) {
|
|
234
|
+
if (event.name !== 'exception') continue;
|
|
235
|
+
const attrs = event.attributes ?? {};
|
|
236
|
+
const message = (attrs[SEMATTRS_EXCEPTION_MESSAGE] as string) ?? 'Unknown error';
|
|
237
|
+
const stack = (attrs[SEMATTRS_EXCEPTION_STACKTRACE] as string) ?? '';
|
|
238
|
+
const type = (attrs[SEMATTRS_EXCEPTION_TYPE] as string) ?? 'Error';
|
|
239
|
+
|
|
240
|
+
const synthetic = new Error(message);
|
|
241
|
+
synthetic.name = type;
|
|
242
|
+
if (stack) synthetic.stack = stack;
|
|
243
|
+
|
|
244
|
+
const { attributes, resource } = getOtelContextFromSpan(otelSpan);
|
|
245
|
+
captureException(synthetic, {
|
|
246
|
+
contexts: {
|
|
247
|
+
otel: { attributes, resource },
|
|
248
|
+
trace: {
|
|
249
|
+
trace_id: otelSpan.spanContext().traceId,
|
|
250
|
+
span_id: otelSpan.spanContext().spanId,
|
|
251
|
+
parent_span_id: otelSpan.parentSpanContext?.spanId,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autotel-sentry: Bridge OpenTelemetry (Autotel) traces to Sentry.
|
|
3
|
+
*/
|
|
4
|
+
export { SentrySpanProcessor, createSentrySpanProcessor } from './processor';
|
|
5
|
+
export type { SentryLike } from './processor';
|
|
6
|
+
export { SentryPropagator, SENTRY_PROPAGATION_KEY } from './propagator';
|
|
7
|
+
export type { SentryPropagationData } from './propagator';
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { SentrySpanProcessor, createSentrySpanProcessor } from './processor';
|
|
3
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
4
|
+
import type { Span } from '@opentelemetry/sdk-trace-base';
|
|
5
|
+
import { context } from '@opentelemetry/api';
|
|
6
|
+
|
|
7
|
+
function createMockReadableSpan(overrides: Partial<{
|
|
8
|
+
name: string;
|
|
9
|
+
spanId: string;
|
|
10
|
+
traceId: string;
|
|
11
|
+
parentSpanId: string;
|
|
12
|
+
attributes: Record<string, unknown>;
|
|
13
|
+
resource: { attributes: Record<string, unknown> };
|
|
14
|
+
events: Array<{ name: string; attributes?: Record<string, unknown> }>;
|
|
15
|
+
status: { code: number };
|
|
16
|
+
kind: number;
|
|
17
|
+
startTime: [number, number];
|
|
18
|
+
endTime: [number, number];
|
|
19
|
+
}> = {}): ReadableSpan {
|
|
20
|
+
return {
|
|
21
|
+
name: 'test-span',
|
|
22
|
+
kind: 0,
|
|
23
|
+
spanContext: () => ({
|
|
24
|
+
traceId: overrides.traceId ?? 'trace123',
|
|
25
|
+
spanId: overrides.spanId ?? 'span456',
|
|
26
|
+
traceFlags: 1,
|
|
27
|
+
}),
|
|
28
|
+
parentSpanContext: overrides.parentSpanId
|
|
29
|
+
? { traceId: 'trace123', spanId: overrides.parentSpanId, traceFlags: 1 }
|
|
30
|
+
: undefined,
|
|
31
|
+
startTime: overrides.startTime ?? [0, 0],
|
|
32
|
+
endTime: overrides.endTime ?? [1, 0],
|
|
33
|
+
status: { code: overrides.status?.code ?? 1 },
|
|
34
|
+
attributes: overrides.attributes ?? {},
|
|
35
|
+
resource: overrides.resource ?? { attributes: { 'service.name': 'test' } },
|
|
36
|
+
events: overrides.events ?? [],
|
|
37
|
+
links: [],
|
|
38
|
+
duration: [1, 0],
|
|
39
|
+
ended: true,
|
|
40
|
+
instrumentationScope: { name: 'test', version: '1.0' },
|
|
41
|
+
droppedAttributesCount: 0,
|
|
42
|
+
droppedEventsCount: 0,
|
|
43
|
+
droppedLinksCount: 0,
|
|
44
|
+
} as unknown as ReadableSpan;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createMockSpan(overrides: Partial<{ name: string; spanId: string; parentSpanId: string }> = {}): Span {
|
|
48
|
+
const spanContext = () => ({
|
|
49
|
+
traceId: 'trace123',
|
|
50
|
+
spanId: overrides.spanId ?? 'span456',
|
|
51
|
+
traceFlags: 1,
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
name: overrides.name ?? 'test-span',
|
|
55
|
+
spanContext,
|
|
56
|
+
parentSpanContext: overrides.parentSpanId
|
|
57
|
+
? { traceId: 'trace123', spanId: overrides.parentSpanId, traceFlags: 1 }
|
|
58
|
+
: undefined,
|
|
59
|
+
startTime: [0, 0],
|
|
60
|
+
} as unknown as Span;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('SentrySpanProcessor', () => {
|
|
64
|
+
let mockSentry: {
|
|
65
|
+
getCurrentHub: ReturnType<typeof vi.fn>;
|
|
66
|
+
addGlobalEventProcessor: ReturnType<typeof vi.fn>;
|
|
67
|
+
captureException: ReturnType<typeof vi.fn>;
|
|
68
|
+
};
|
|
69
|
+
let mockHub: {
|
|
70
|
+
startTransaction: ReturnType<typeof vi.fn>;
|
|
71
|
+
getSpan: ReturnType<typeof vi.fn>;
|
|
72
|
+
};
|
|
73
|
+
let mockTransaction: {
|
|
74
|
+
startChild: ReturnType<typeof vi.fn>;
|
|
75
|
+
setStatus: ReturnType<typeof vi.fn>;
|
|
76
|
+
setContext: ReturnType<typeof vi.fn>;
|
|
77
|
+
finish: ReturnType<typeof vi.fn>;
|
|
78
|
+
};
|
|
79
|
+
let mockChildSpan: {
|
|
80
|
+
setStatus: ReturnType<typeof vi.fn>;
|
|
81
|
+
setData: ReturnType<typeof vi.fn>;
|
|
82
|
+
finish: ReturnType<typeof vi.fn>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
mockTransaction = {
|
|
87
|
+
startChild: vi.fn(),
|
|
88
|
+
setStatus: vi.fn(),
|
|
89
|
+
setContext: vi.fn(),
|
|
90
|
+
finish: vi.fn(),
|
|
91
|
+
};
|
|
92
|
+
mockChildSpan = {
|
|
93
|
+
setStatus: vi.fn(),
|
|
94
|
+
setData: vi.fn(),
|
|
95
|
+
finish: vi.fn(),
|
|
96
|
+
};
|
|
97
|
+
mockHub = {
|
|
98
|
+
startTransaction: vi.fn(() => mockTransaction),
|
|
99
|
+
getSpan: vi.fn(),
|
|
100
|
+
};
|
|
101
|
+
mockSentry = {
|
|
102
|
+
getCurrentHub: vi.fn(() => mockHub),
|
|
103
|
+
addGlobalEventProcessor: vi.fn(),
|
|
104
|
+
captureException: vi.fn(),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('registers a global event processor in constructor', () => {
|
|
109
|
+
new SentrySpanProcessor(mockSentry as any);
|
|
110
|
+
expect(mockSentry.addGlobalEventProcessor).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(typeof mockSentry.addGlobalEventProcessor.mock.calls[0][0]).toBe('function');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('onStart creates a transaction when no parent', () => {
|
|
115
|
+
const processor = new SentrySpanProcessor(mockSentry as any);
|
|
116
|
+
const span = createMockSpan({ spanId: 'root' });
|
|
117
|
+
processor.onStart(span, context.active());
|
|
118
|
+
expect(mockHub.startTransaction).toHaveBeenCalledWith(
|
|
119
|
+
expect.objectContaining({
|
|
120
|
+
name: 'test-span',
|
|
121
|
+
traceId: 'trace123',
|
|
122
|
+
spanId: 'root',
|
|
123
|
+
instrumenter: 'otel',
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('onStart creates a child span when parent exists in map', () => {
|
|
129
|
+
const processor = new SentrySpanProcessor(mockSentry as any);
|
|
130
|
+
const parentSpan = createMockSpan({ spanId: 'parent' });
|
|
131
|
+
const childSpan = createMockSpan({ spanId: 'child', parentSpanId: 'parent' });
|
|
132
|
+
processor.onStart(parentSpan, context.active());
|
|
133
|
+
mockHub.startTransaction.mockReturnValue(mockTransaction);
|
|
134
|
+
processor.onStart(childSpan, context.active());
|
|
135
|
+
expect(mockTransaction.startChild).toHaveBeenCalledWith(
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
description: 'test-span',
|
|
138
|
+
spanId: 'child',
|
|
139
|
+
instrumenter: 'otel',
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('onEnd updates and finishes transaction', () => {
|
|
145
|
+
const processor = new SentrySpanProcessor(mockSentry as any);
|
|
146
|
+
const span = createMockSpan({ spanId: 'root' });
|
|
147
|
+
processor.onStart(span, context.active());
|
|
148
|
+
const readableSpan = createMockReadableSpan({ spanId: 'root', traceId: 'trace123' });
|
|
149
|
+
(readableSpan as any).spanContext = () => ({ traceId: 'trace123', spanId: 'root' });
|
|
150
|
+
processor.onEnd(readableSpan);
|
|
151
|
+
expect(mockTransaction.setStatus).toHaveBeenCalled();
|
|
152
|
+
expect(mockTransaction.setContext).toHaveBeenCalledWith('otel', expect.any(Object));
|
|
153
|
+
expect(mockTransaction.finish).toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('onEnd updates and finishes child span', () => {
|
|
157
|
+
const processor = new SentrySpanProcessor(mockSentry as any);
|
|
158
|
+
const parentSpan = createMockSpan({ spanId: 'parent' });
|
|
159
|
+
const childSpan = createMockSpan({ spanId: 'child', parentSpanId: 'parent' });
|
|
160
|
+
processor.onStart(parentSpan, context.active());
|
|
161
|
+
mockTransaction.startChild.mockReturnValue(mockChildSpan);
|
|
162
|
+
processor.onStart(childSpan, context.active());
|
|
163
|
+
const readableChild = createMockReadableSpan({
|
|
164
|
+
spanId: 'child',
|
|
165
|
+
parentSpanId: 'parent',
|
|
166
|
+
});
|
|
167
|
+
(readableChild as any).spanContext = () => ({ traceId: 'trace123', spanId: 'child' });
|
|
168
|
+
processor.onEnd(readableChild);
|
|
169
|
+
expect(mockChildSpan.setStatus).toHaveBeenCalled();
|
|
170
|
+
expect(mockChildSpan.finish).toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('onEnd skips finishing when span is Sentry request', () => {
|
|
174
|
+
const processor = new SentrySpanProcessor(mockSentry as any);
|
|
175
|
+
const span = createMockSpan({ spanId: 'root' });
|
|
176
|
+
processor.onStart(span, context.active());
|
|
177
|
+
const readableSpan = createMockReadableSpan({
|
|
178
|
+
spanId: 'root',
|
|
179
|
+
attributes: { 'http.url': 'https://sentry.io/api/123/envelope/' },
|
|
180
|
+
});
|
|
181
|
+
(readableSpan as any).spanContext = () => ({ traceId: 'trace123', spanId: 'root' });
|
|
182
|
+
const hubWithDsn = {
|
|
183
|
+
...mockHub,
|
|
184
|
+
getClient: () => ({ getDsn: () => ({ host: 'sentry.io' }) }),
|
|
185
|
+
};
|
|
186
|
+
mockSentry.getCurrentHub.mockReturnValue(hubWithDsn);
|
|
187
|
+
processor.onEnd(readableSpan);
|
|
188
|
+
expect(mockTransaction.finish).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('createSentrySpanProcessor returns a SentrySpanProcessor instance', () => {
|
|
192
|
+
const processor = createSentrySpanProcessor(mockSentry as any);
|
|
193
|
+
expect(processor).toBeInstanceOf(SentrySpanProcessor);
|
|
194
|
+
});
|
|
195
|
+
});
|
package/src/processor.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SentrySpanProcessor: converts OpenTelemetry spans to Sentry transactions/spans.
|
|
3
|
+
* Register with init({ spanProcessors: [new SentrySpanProcessor(Sentry)] }).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { trace } from '@opentelemetry/api';
|
|
7
|
+
import type { Context } from '@opentelemetry/api';
|
|
8
|
+
import type { Span, SpanProcessor } from '@opentelemetry/sdk-trace-base';
|
|
9
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
10
|
+
import {
|
|
11
|
+
convertOtelTimeToSeconds,
|
|
12
|
+
getTraceData,
|
|
13
|
+
isSentryRequestSpan,
|
|
14
|
+
updateSpanWithOtelData,
|
|
15
|
+
updateTransactionWithOtelData,
|
|
16
|
+
finishTransactionWithContextFromOtelData,
|
|
17
|
+
generateSentryErrorsFromOtelSpan,
|
|
18
|
+
} from './helpers';
|
|
19
|
+
|
|
20
|
+
/** Minimal Sentry hub interface for creating transactions and spans. */
|
|
21
|
+
export interface SentryHubLike {
|
|
22
|
+
startTransaction(ctx: SentryTransactionContextLike): SentryTransactionLike | undefined;
|
|
23
|
+
getSpan(): SentrySpanLike | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Context passed to startTransaction. */
|
|
27
|
+
export interface SentryTransactionContextLike {
|
|
28
|
+
name: string;
|
|
29
|
+
traceId?: string;
|
|
30
|
+
spanId?: string;
|
|
31
|
+
parentSpanId?: string;
|
|
32
|
+
startTimestamp?: number;
|
|
33
|
+
instrumenter?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Sentry transaction (root span). */
|
|
37
|
+
export interface SentryTransactionLike {
|
|
38
|
+
startChild(ctx: SentrySpanContextLike): SentrySpanLike;
|
|
39
|
+
setStatus(s: { status?: string }): void;
|
|
40
|
+
setContext(name: string, ctx: Record<string, unknown>): void;
|
|
41
|
+
finish(endTime?: number): void;
|
|
42
|
+
name?: string;
|
|
43
|
+
op?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Context passed to startChild. */
|
|
47
|
+
export interface SentrySpanContextLike {
|
|
48
|
+
description?: string;
|
|
49
|
+
instrumenter?: string;
|
|
50
|
+
startTimestamp?: number;
|
|
51
|
+
spanId?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Sentry span (child or transaction). */
|
|
55
|
+
export interface SentrySpanLike {
|
|
56
|
+
setStatus(s: { status?: string }): void;
|
|
57
|
+
setData(key: string, value: unknown): void;
|
|
58
|
+
finish(endTime?: number): void;
|
|
59
|
+
op?: string;
|
|
60
|
+
description?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Minimal Sentry SDK interface used by the processor. */
|
|
64
|
+
export interface SentryLike {
|
|
65
|
+
getCurrentHub(): SentryHubLike;
|
|
66
|
+
addGlobalEventProcessor(callback: (event: unknown) => unknown): void;
|
|
67
|
+
captureException(error: Error, options?: { contexts?: Record<string, unknown> }): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Client with getDsn() for isSentryRequest detection. */
|
|
71
|
+
interface SentryClientLike {
|
|
72
|
+
getDsn(): { host: string } | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const INSTRUMENTER_OTEL = 'otel';
|
|
76
|
+
|
|
77
|
+
export class SentrySpanProcessor implements SpanProcessor {
|
|
78
|
+
private readonly sentry: SentryLike;
|
|
79
|
+
private readonly map = new Map<string, SentrySpanLike | SentryTransactionLike>();
|
|
80
|
+
|
|
81
|
+
constructor(sentry: SentryLike) {
|
|
82
|
+
this.sentry = sentry;
|
|
83
|
+
|
|
84
|
+
if (typeof sentry.addGlobalEventProcessor === 'function') {
|
|
85
|
+
sentry.addGlobalEventProcessor((event: unknown) => {
|
|
86
|
+
const e = event as { contexts?: Record<string, unknown> };
|
|
87
|
+
const otelSpan = trace.getActiveSpan();
|
|
88
|
+
if (!otelSpan) return e;
|
|
89
|
+
|
|
90
|
+
if (e.contexts && (e.contexts as Record<string, unknown>).trace) {
|
|
91
|
+
return e;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ctx = otelSpan.spanContext();
|
|
95
|
+
e.contexts = {
|
|
96
|
+
...e.contexts,
|
|
97
|
+
trace: {
|
|
98
|
+
trace_id: ctx.traceId,
|
|
99
|
+
span_id: ctx.spanId,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
return e;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private getDsnHost(): string | undefined {
|
|
108
|
+
const hub = this.sentry.getCurrentHub();
|
|
109
|
+
const client = (hub as unknown as { getClient?(): SentryClientLike }).getClient?.();
|
|
110
|
+
return client?.getDsn()?.host;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
onStart(span: Span, _parentContext: Context): void {
|
|
114
|
+
const hub = this.sentry.getCurrentHub();
|
|
115
|
+
if (!hub) return;
|
|
116
|
+
|
|
117
|
+
const spanContext = span.spanContext();
|
|
118
|
+
const otelSpanId = spanContext.spanId;
|
|
119
|
+
const parentSpanId = span.parentSpanContext?.spanId;
|
|
120
|
+
const parentSentry = parentSpanId ? this.map.get(parentSpanId) : undefined;
|
|
121
|
+
|
|
122
|
+
const startTimestamp = convertOtelTimeToSeconds(span.startTime);
|
|
123
|
+
|
|
124
|
+
if (parentSentry && 'startChild' in parentSentry) {
|
|
125
|
+
const child = parentSentry.startChild({
|
|
126
|
+
description: span.name,
|
|
127
|
+
instrumenter: INSTRUMENTER_OTEL,
|
|
128
|
+
startTimestamp,
|
|
129
|
+
spanId: otelSpanId,
|
|
130
|
+
});
|
|
131
|
+
this.map.set(otelSpanId, child);
|
|
132
|
+
} else {
|
|
133
|
+
const traceData = getTraceData(span as unknown as ReadableSpan);
|
|
134
|
+
const transaction = hub.startTransaction({
|
|
135
|
+
name: span.name,
|
|
136
|
+
traceId: traceData.traceId,
|
|
137
|
+
spanId: traceData.spanId,
|
|
138
|
+
parentSpanId: traceData.parentSpanId,
|
|
139
|
+
startTimestamp,
|
|
140
|
+
instrumenter: INSTRUMENTER_OTEL,
|
|
141
|
+
});
|
|
142
|
+
if (transaction) {
|
|
143
|
+
this.map.set(otelSpanId, transaction);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
onEnd(span: ReadableSpan): void {
|
|
149
|
+
const otelSpanId = span.spanContext().spanId;
|
|
150
|
+
const sentrySpan = this.map.get(otelSpanId);
|
|
151
|
+
|
|
152
|
+
if (isSentryRequestSpan(span, () => this.getDsnHost())) {
|
|
153
|
+
this.map.delete(otelSpanId);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
generateSentryErrorsFromOtelSpan(span, (err, opts) =>
|
|
158
|
+
this.sentry.captureException(err, opts),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (!sentrySpan) return;
|
|
162
|
+
|
|
163
|
+
const isTransaction = 'setContext' in sentrySpan && 'finish' in sentrySpan;
|
|
164
|
+
|
|
165
|
+
if (isTransaction) {
|
|
166
|
+
updateTransactionWithOtelData(sentrySpan as SentryTransactionLike, span);
|
|
167
|
+
finishTransactionWithContextFromOtelData(
|
|
168
|
+
sentrySpan as SentryTransactionLike,
|
|
169
|
+
span,
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
updateSpanWithOtelData(sentrySpan as SentrySpanLike, span);
|
|
173
|
+
sentrySpan.finish(convertOtelTimeToSeconds(span.endTime));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.map.delete(otelSpanId);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
forceFlush(): Promise<void> {
|
|
180
|
+
return Promise.resolve();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
shutdown(): Promise<void> {
|
|
184
|
+
this.map.clear();
|
|
185
|
+
return Promise.resolve();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createSentrySpanProcessor(sentry: SentryLike): SentrySpanProcessor {
|
|
190
|
+
return new SentrySpanProcessor(sentry);
|
|
191
|
+
}
|