Skip to content
Open
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
3 changes: 2 additions & 1 deletion lib/tracing/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as bootstrap from './bootstrap';

export { makeHttpInstrumentationConfig } from './httpHooks';
export { instrumentApiMethod, endSpan } from './instrumentation';
export { instrumentApiMethod, endSpan, startApiSpan } from './instrumentation';
export type { ApiSpan } from './instrumentation';
export type { InitOptions } from './bootstrap';
export * as kafka from './kafkaTraceContext';

Expand Down
30 changes: 30 additions & 0 deletions lib/tracing/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,33 @@ export function instrumentApiMethod<T extends (...args: any[]) => any>(apiMethod
return instrumentAsyncHandler(this, apiMethod, spanName, args);
} as T;
}

// Manual span lifecycle for consumers whose dispatch owns the start + end sites
// directly (e.g. a centralized Router method). Prefer `instrumentApiMethod` for
// flat dispatch tables where wrap-once-at-module-load fits naturally.
export interface ApiSpan {
// `end()` marks the span OK; `end(err)` marks it ERROR + records the
// exception (mirrors the err-as-optional shape of `endSpan` above).
end(err?: any): void;
// Runs `fn` with the span set as the active context so child auto-spans
// (mongo, ioredis, http) nest underneath.
withContext<T>(fn: () => T): T;
}

const NO_OP_API_SPAN: ApiSpan = {
end: () => {},
withContext: fn => fn(),
};

export function startApiSpan(action: string): ApiSpan {
if (!isEnabled()) {
return NO_OP_API_SPAN;
}
const { trace, context, SpanKind } = getApi();
const span = getTracer().startSpan(`${SPAN_PREFIX}${action}`, { kind: SpanKind.INTERNAL });
const ctx = trace.setSpan(context.active(), span);
return {
end: err => endSpan(span, err),
withContext: fn => context.with(ctx, fn),
};
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"engines": {
"node": ">=20"
},
"version": "8.4.6",
"version": "8.4.7",
"description": "Common utilities for the S3 project components",
"main": "build/index.js",
"repository": {
Expand Down
108 changes: 106 additions & 2 deletions tests/unit/tracing/instrumentation.spec.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
'use strict';

const assert = require('assert');
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const { trace, context, SpanStatusCode } = require('@opentelemetry/api');
const { AsyncLocalStorageContextManager } = require('@opentelemetry/context-async-hooks');
const {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
AlwaysOnSampler,
} = require('@opentelemetry/sdk-trace-base');

const { instrumentApiMethod, resetTracer } = require('../../../lib/tracing/instrumentation');
const { instrumentApiMethod, startApiSpan, resetTracer } = require('../../../lib/tracing/instrumentation');

describe('instrumentApiMethod', () => {
let exporter;
Expand Down Expand Up @@ -155,3 +156,106 @@ describe('instrumentApiMethod', () => {
});
});
});

describe('startApiSpan', () => {
let exporter;
let provider;
let contextManager;

beforeAll(() => {
process.env.ENABLE_OTEL = 'true';
exporter = new InMemorySpanExporter();
provider = new BasicTracerProvider({
sampler: new AlwaysOnSampler(),
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);
// Default ContextManager is a no-op (returns ROOT), so context.with
// wouldn't actually propagate. Real-world NodeSDK installs one;
// mirror that for the withContext assertions below.
contextManager = new AsyncLocalStorageContextManager().enable();
context.setGlobalContextManager(contextManager);
resetTracer();
});

afterAll(async () => {
delete process.env.ENABLE_OTEL;
resetTracer();
contextManager.disable();
context.disable();
await provider.shutdown();
trace.disable();
});

describe('OTEL on', () => {
afterEach(() => exporter.reset());

it('should end with status OK on end() with no error', () => {
const span = startApiSpan('AuthV4');
span.end();

const spans = exporter.getFinishedSpans();
assert.strictEqual(spans.length, 1);
assert.strictEqual(spans[0].name, 'api.AuthV4');
assert.strictEqual(spans[0].status.code, SpanStatusCode.OK);
});

it('should end with status ERROR + error.type on end(err)', () => {
const span = startApiSpan('AssumeRole');
const err = Object.assign(new Error('denied'), { code: 'AccessDenied' });
span.end(err);

const spans = exporter.getFinishedSpans();
assert.strictEqual(spans.length, 1);
assert.strictEqual(spans[0].status.code, SpanStatusCode.ERROR);
assert.strictEqual(spans[0].attributes['error.type'], 'AccessDenied');
});

it('should set the started span as the active context inside withContext(fn)', () => {
// Run inside an outer span so the active span is not the same as
// ours before withContext fires — that's how we tell the inner
// span is the one that propagated through context.with.
const outerSpan = trace.getTracer('outer').startSpan('outer');
context.with(trace.setSpan(context.active(), outerSpan), () => {
const before = trace.getActiveSpan();
const span = startApiSpan('CheckPolicies');
let inside;
span.withContext(() => {
inside = trace.getActiveSpan();
});
const after = trace.getActiveSpan();
span.end();

assert.strictEqual(before, outerSpan);
assert.ok(inside, 'expected an active span inside withContext');
assert.notStrictEqual(inside, outerSpan);
assert.strictEqual(after, outerSpan);
});
outerSpan.end();
});

it('should return the value `fn` returns from withContext', () => {
const span = startApiSpan('GetCallerIdentity');
const value = span.withContext(() => 42);
span.end();
assert.strictEqual(value, 42);
});
});

describe('OTEL off', () => {
afterEach(() => {
process.env.ENABLE_OTEL = 'true';
});

it('returns a no-op span — end()/end(err)/withContext do not throw', () => {
process.env.ENABLE_OTEL = 'false';
const span = startApiSpan('AuthV4');
assert.doesNotThrow(() => span.end());
assert.doesNotThrow(() => span.end(new Error('boom')));
assert.strictEqual(
span.withContext(() => 'value'),
'value',
);
});
});
});
Loading