Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(otel): Map otel span data to Sentry transaction/span/context #6082

Merged
merged 4 commits into from Oct 28, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/opentelemetry-node/package.json
Expand Up @@ -24,7 +24,8 @@
},
"peerDependencies": {
"@opentelemetry/api": "1.x",
"@opentelemetry/sdk-trace-base": "1.x"
"@opentelemetry/sdk-trace-base": "1.x",
"@opentelemetry/semantic-conventions": "1.x"
},
"devDependencies": {
"@opentelemetry/api": "^1.2.0",
Expand Down
43 changes: 34 additions & 9 deletions packages/opentelemetry-node/src/spanprocessor.ts
@@ -1,9 +1,12 @@
import { Context } from '@opentelemetry/api';
import { Span as OtelSpan, SpanProcessor as OtelSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { getCurrentHub } from '@sentry/core';
import { getCurrentHub, setContext } from '@sentry/core';
import { Transaction } from '@sentry/tracing';
import { Span as SentrySpan, TransactionContext } from '@sentry/types';
import { logger } from '@sentry/utils';

import { mapOtelStatus } from './utils/map-otel-status';

/**
* Converts OpenTelemetry Spans to Sentry Spans and sends them to Sentry via
* the Sentry SDK.
Expand Down Expand Up @@ -65,18 +68,20 @@ export class SentrySpanProcessor implements OtelSpanProcessor {
*/
public onEnd(otelSpan: OtelSpan): void {
const otelSpanId = otelSpan.spanContext().spanId;
const mapVal = this._map.get(otelSpanId);
const sentrySpan = this._map.get(otelSpanId);

if (!mapVal) {
if (!sentrySpan) {
__DEBUG_BUILD__ &&
logger.error(`SentrySpanProcessor could not find span with OTEL-spanId ${otelSpanId} to finish.`);
return;
}

const sentrySpan = mapVal;

// TODO: actually add context etc. to span
// updateSpanWithOtelData(sentrySpan, otelSpan);
if (sentrySpan instanceof Transaction) {
updateContextWithOtelData(otelSpan);
updateTransactionWithOtelData(sentrySpan, otelSpan);
} else {
updateSpanWithOtelData(sentrySpan, otelSpan);
}

sentrySpan.finish(otelSpan.endTime[0]);

Expand Down Expand Up @@ -111,5 +116,25 @@ function getTraceData(otelSpan: OtelSpan): Partial<TransactionContext> {
return { spanId, traceId, parentSpanId };
}

// function updateSpanWithOtelData(sentrySpan: SentrySpan, otelSpan: OtelSpan): void {
// }
function updateContextWithOtelData(otelSpan: OtelSpan): void {
setContext('otel', {
mydea marked this conversation as resolved.
Show resolved Hide resolved
attributes: otelSpan.attributes,
resource: otelSpan.resource.attributes,
});
}

function updateSpanWithOtelData(sentrySpan: SentrySpan, otelSpan: OtelSpan): void {
const { attributes, kind } = otelSpan;

sentrySpan.setStatus(mapOtelStatus(otelSpan));
sentrySpan.setData('otel.kind', kind.valueOf());

Object.keys(attributes).forEach(prop => {
const value = attributes[prop];
sentrySpan.setData(prop, value);
});
}

function updateTransactionWithOtelData(transaction: Transaction, otelSpan: OtelSpan): void {
transaction.setStatus(mapOtelStatus(otelSpan));
}
77 changes: 77 additions & 0 deletions packages/opentelemetry-node/src/utils/map-otel-status.ts
@@ -0,0 +1,77 @@
import { Span as OtelSpan } from '@opentelemetry/sdk-trace-base';
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
import { SpanStatusType as SentryStatus } from '@sentry/tracing';

// canonicalCodesHTTPMap maps some HTTP codes to Sentry's span statuses. See possible mapping in https://develop.sentry.dev/sdk/event-payloads/span/
const canonicalCodesHTTPMap: Record<string, SentryStatus> = {
'400': 'failed_precondition',
'401': 'unauthenticated',
'403': 'permission_denied',
'404': 'not_found',
'409': 'aborted',
'429': 'resource_exhausted',
'499': 'cancelled',
'500': 'internal_error',
'501': 'unimplemented',
'503': 'unavailable',
'504': 'deadline_exceeded',
} as const;

// canonicalCodesGrpcMap maps some GRPC codes to Sentry's span statuses. See description in grpc documentation.
const canonicalCodesGrpcMap: Record<string, SentryStatus> = {
'1': 'cancelled',
'2': 'unknown_error',
'3': 'invalid_argument',
'4': 'deadline_exceeded',
'5': 'not_found',
'6': 'already_exists',
'7': 'permission_denied',
'8': 'resource_exhausted',
'9': 'failed_precondition',
'10': 'aborted',
'11': 'out_of_range',
'12': 'unimplemented',
'13': 'internal_error',
'14': 'unavailable',
'15': 'data_loss',
'16': 'unauthenticated',
} as const;

/**
* Get a Sentry span status from an otel span.
*
* @param otelSpan An otel span to generate a sentry status for.
* @returns The Sentry span status
*/
export function mapOtelStatus(otelSpan: OtelSpan): SentryStatus {
const { status, attributes } = otelSpan;

const statusCode = status.code;

if (statusCode < 0 || statusCode > 2) {
return 'unknown_error';
}

if (statusCode === 0 || statusCode === 1) {
return 'ok';
}

const httpCode = attributes[SemanticAttributes.HTTP_STATUS_CODE];
const grpcCode = attributes[SemanticAttributes.RPC_GRPC_STATUS_CODE];

if (typeof httpCode === 'string') {
const sentryStatus = canonicalCodesHTTPMap[httpCode];
if (sentryStatus) {
return sentryStatus;
}
}

if (typeof grpcCode === 'string') {
const sentryStatus = canonicalCodesGrpcMap[grpcCode];
if (sentryStatus) {
return sentryStatus;
}
}

return 'unknown_error';
}
195 changes: 193 additions & 2 deletions packages/opentelemetry-node/test/spanprocessor.test.ts
@@ -1,8 +1,11 @@
import * as OpenTelemetry from '@opentelemetry/api';
import { Resource } from '@opentelemetry/resources';
import { Span as OtelSpan } from '@opentelemetry/sdk-trace-base';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { SemanticAttributes, SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { Hub, makeMain } from '@sentry/core';
import { addExtensionMethods, Span as SentrySpan, Transaction } from '@sentry/tracing';
import { addExtensionMethods, Span as SentrySpan, SpanStatusType, Transaction } from '@sentry/tracing';
import { Contexts, Scope } from '@sentry/types';

import { SentrySpanProcessor } from '../src/spanprocessor';

Expand All @@ -22,7 +25,11 @@ describe('SentrySpanProcessor', () => {
makeMain(hub);

spanProcessor = new SentrySpanProcessor();
provider = new NodeTracerProvider();
provider = new NodeTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'test-service',
}),
});
provider.addSpanProcessor(spanProcessor);
provider.register();
});
Expand All @@ -36,6 +43,11 @@ describe('SentrySpanProcessor', () => {
return spanProcessor._map.get(otelSpan.spanContext().spanId);
}

function getContext() {
const scope = hub.getScope() as unknown as Scope & { _contexts: Contexts };
return scope._contexts;
}

it('creates a transaction', async () => {
const startTime = otelNumberToHrtime(new Date().valueOf());

Expand Down Expand Up @@ -125,6 +137,185 @@ describe('SentrySpanProcessor', () => {
parentOtelSpan.end();
});
});

it('sets context for transaction', async () => {
const otelSpan = provider.getTracer('default').startSpan('GET /users');

// context is only set after end
expect(getContext()).toEqual({});

otelSpan.end();

expect(getContext()).toEqual({
otel: {
attributes: {},
resource: {
'service.name': 'test-service',
'telemetry.sdk.language': 'nodejs',
'telemetry.sdk.name': 'opentelemetry',
'telemetry.sdk.version': '1.7.0',
},
},
});

// Start new transaction, context should remain the same
const otelSpan2 = provider.getTracer('default').startSpan('GET /companies');

expect(getContext()).toEqual({
otel: {
attributes: {},
resource: {
'service.name': 'test-service',
'telemetry.sdk.language': 'nodejs',
'telemetry.sdk.name': 'opentelemetry',
'telemetry.sdk.version': '1.7.0',
},
},
});

otelSpan2.setAttribute('test-attribute', 'test-value');

otelSpan2.end();

expect(getContext()).toEqual({
otel: {
attributes: {
'test-attribute': 'test-value',
},
resource: {
'service.name': 'test-service',
'telemetry.sdk.language': 'nodejs',
'telemetry.sdk.name': 'opentelemetry',
'telemetry.sdk.version': '1.7.0',
},
},
});
});

it('sets data for span', async () => {
const tracer = provider.getTracer('default');

tracer.startActiveSpan('GET /users', parentOtelSpan => {
tracer.startActiveSpan('SELECT * FROM users;', child => {
child.setAttribute('test-attribute', 'test-value');
child.setAttribute('test-attribute-2', [1, 2, 3]);
child.setAttribute('test-attribute-3', 0);
child.setAttribute('test-attribute-4', false);

const sentrySpan = getSpanForOtelSpan(child);

expect(sentrySpan?.data).toEqual({});

child.end();

expect(sentrySpan?.data).toEqual({
'otel.kind': 0,
'test-attribute': 'test-value',
'test-attribute-2': [1, 2, 3],
'test-attribute-3': 0,
'test-attribute-4': false,
});
});

parentOtelSpan.end();
});
});

it('sets status for transaction', async () => {
const otelSpan = provider.getTracer('default').startSpan('GET /users');

const transaction = getSpanForOtelSpan(otelSpan) as Transaction;

// status is only set after end
expect(transaction?.status).toBe(undefined);

otelSpan.end();

expect(transaction?.status).toBe('ok');
});

it('sets status for span', async () => {
const tracer = provider.getTracer('default');

tracer.startActiveSpan('GET /users', parentOtelSpan => {
tracer.startActiveSpan('SELECT * FROM users;', child => {
const sentrySpan = getSpanForOtelSpan(child);

expect(sentrySpan?.status).toBe(undefined);

child.end();

expect(sentrySpan?.status).toBe('ok');

parentOtelSpan.end();
});
});
});

const statusTestTable: [number, undefined | string, undefined | string, SpanStatusType][] = [
[-1, undefined, undefined, 'unknown_error'],
[3, undefined, undefined, 'unknown_error'],
[0, undefined, undefined, 'ok'],
[1, undefined, undefined, 'ok'],
[2, undefined, undefined, 'unknown_error'],

// http codes
[2, '400', undefined, 'failed_precondition'],
[2, '401', undefined, 'unauthenticated'],
[2, '403', undefined, 'permission_denied'],
[2, '404', undefined, 'not_found'],
[2, '409', undefined, 'aborted'],
[2, '429', undefined, 'resource_exhausted'],
[2, '499', undefined, 'cancelled'],
[2, '500', undefined, 'internal_error'],
[2, '501', undefined, 'unimplemented'],
[2, '503', undefined, 'unavailable'],
[2, '504', undefined, 'deadline_exceeded'],
[2, '999', undefined, 'unknown_error'],

// grpc codes
[2, undefined, '1', 'cancelled'],
[2, undefined, '2', 'unknown_error'],
[2, undefined, '3', 'invalid_argument'],
[2, undefined, '4', 'deadline_exceeded'],
[2, undefined, '5', 'not_found'],
[2, undefined, '6', 'already_exists'],
[2, undefined, '7', 'permission_denied'],
[2, undefined, '8', 'resource_exhausted'],
[2, undefined, '9', 'failed_precondition'],
[2, undefined, '10', 'aborted'],
[2, undefined, '11', 'out_of_range'],
[2, undefined, '12', 'unimplemented'],
[2, undefined, '13', 'internal_error'],
[2, undefined, '14', 'unavailable'],
[2, undefined, '15', 'data_loss'],
[2, undefined, '16', 'unauthenticated'],
[2, undefined, '999', 'unknown_error'],

// http takes precedence over grpc
[2, '400', '2', 'failed_precondition'],
];

it.each(statusTestTable)(
'correctly converts otel span status to sentry status with otelStatus=%i, httpCode=%s, grpcCode=%s',
(otelStatus, httpCode, grpcCode, expected) => {
const otelSpan = provider.getTracer('default').startSpan('GET /users');
const transaction = getSpanForOtelSpan(otelSpan) as Transaction;

otelSpan.setStatus({ code: otelStatus });

if (httpCode) {
otelSpan.setAttribute(SemanticAttributes.HTTP_STATUS_CODE, httpCode);
}

if (grpcCode) {
otelSpan.setAttribute(SemanticAttributes.RPC_GRPC_STATUS_CODE, grpcCode);
}

otelSpan.end();
expect(transaction?.status).toBe(expected);
},
);
});

// OTEL expects a custom date format
Expand Down