Skip to content

Commit

Permalink
feat(otel): Map otel span data to Sentry transaction/span/context (#6082
Browse files Browse the repository at this point in the history
)

Enhance sentry transactions/spans/context with data from OpenTelemetry spans, when using otel-integration.

* otel attributes, resource for transaction --> sentry context
* otel attributes, kind for span --> sentry data
* otel status + data from attributes --> sentry status (for both transactions & spans)
  • Loading branch information
mydea committed Oct 28, 2022
1 parent c6bdbfd commit b0908d0
Show file tree
Hide file tree
Showing 4 changed files with 323 additions and 14 deletions.
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
50 changes: 39 additions & 11 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, withScope } 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,20 +68,21 @@ 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);

sentrySpan.finish(otelSpan.endTime[0]);
if (sentrySpan instanceof Transaction) {
updateTransactionWithOtelData(sentrySpan, otelSpan);
finishTransactionWithContextFromOtelData(sentrySpan, otelSpan);
} else {
updateSpanWithOtelData(sentrySpan, otelSpan);
sentrySpan.finish(otelSpan.endTime[0]);
}

this._map.delete(otelSpanId);
}
Expand Down Expand Up @@ -111,5 +115,29 @@ function getTraceData(otelSpan: OtelSpan): Partial<TransactionContext> {
return { spanId, traceId, parentSpanId };
}

// function updateSpanWithOtelData(sentrySpan: SentrySpan, otelSpan: OtelSpan): void {
// }
function finishTransactionWithContextFromOtelData(transaction: Transaction, otelSpan: OtelSpan): void {
withScope(scope => {
scope.setContext('otel', {
attributes: otelSpan.attributes,
resource: otelSpan.resource.attributes,
});

transaction.finish(otelSpan.endTime[0]);
});
}

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';
}
207 changes: 205 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,27 @@ describe('SentrySpanProcessor', () => {
return spanProcessor._map.get(otelSpan.spanContext().spanId);
}

function getContext(transaction: Transaction) {
const transactionWithContext = transaction as unknown as Transaction & { _contexts: Contexts };
return transactionWithContext._contexts;
}

// monkey-patch finish to store the context at finish time
function monkeyPatchTransactionFinish(transaction: Transaction) {
const monkeyPatchedTransaction = transaction as Transaction & { _contexts: Contexts };

// eslint-disable-next-line @typescript-eslint/unbound-method
const originalFinish = monkeyPatchedTransaction.finish;
monkeyPatchedTransaction._contexts = {};
monkeyPatchedTransaction.finish = function (endTimestamp?: number | undefined) {
monkeyPatchedTransaction._contexts = (
transaction._hub.getScope() as unknown as Scope & { _contexts: Contexts }
)._contexts;

return originalFinish.apply(monkeyPatchedTransaction, [endTimestamp]);
};
}

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

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

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

const transaction = getSpanForOtelSpan(otelSpan) as Transaction;
monkeyPatchTransactionFinish(transaction);

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

otelSpan.end();

expect(getContext(transaction)).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
const otelSpan2 = provider.getTracer('default').startSpan('GET /companies');

const transaction2 = getSpanForOtelSpan(otelSpan2) as Transaction;
monkeyPatchTransactionFinish(transaction2);

expect(getContext(transaction2)).toEqual({});

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

otelSpan2.end();

expect(getContext(transaction2)).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

0 comments on commit b0908d0

Please sign in to comment.