Skip to content

Commit a1fe7ca

Browse files
committed
feat: stamp W3C trace context on MongoDB metadata writes
Add a traceContext field to ObjectMD carrying the currently-active OTEL trace context (W3C traceparent/tracestate). Inject it automatically at the three metadata write chokepoints where originOp is set today: internalPutObject, repair, and internalDeleteObject. The value ends up in the MongoDB oplog; downstream consumers (backbeat, sorbet) can extract it to continue the trace across the async boundary, closing the loop on end-to-end tracing for flows that cross the oplog. When OTEL is not active on the caller (no SDK, or request outside a traced context), captureCurrentTraceContext returns undefined, setTraceContext no-ops, and the field stays absent — zero cost. Only adds @opentelemetry/api as a runtime dependency (the API-only surface package, becomes a no-op when no SDK is registered). Issue: ARSN-572
1 parent 600fb06 commit a1fe7ca

7 files changed

Lines changed: 222 additions & 0 deletions

File tree

lib/models/ObjectMD.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ export type ObjectMDData = {
8989
replicationInfo: ReplicationInfo;
9090
dataStoreName: string;
9191
originOp: string;
92+
traceContext?: {
93+
traceparent?: string;
94+
tracestate?: string;
95+
};
9296
microVersionId?: string;
9397
// Deletion flag
9498
// Used for keeping object metadata in the oplog event
@@ -1442,6 +1446,33 @@ export default class ObjectMD {
14421446
return this._data.originOp;
14431447
}
14441448

1449+
/**
1450+
* Attach a W3C trace context to the metadata so it ends up in
1451+
* the MongoDB oplog and downstream consumers can continue the trace.
1452+
* Always reflects the current write: passing undefined (or a value
1453+
* without traceparent) clears any previously-set traceContext so that
1454+
* a stale context does not get carried forward from an existing
1455+
* ObjectMD loaded from storage.
1456+
* @param tc - W3C trace context carrier
1457+
* @return itself
1458+
*/
1459+
setTraceContext(tc?: { traceparent?: string; tracestate?: string }) {
1460+
if (tc && tc.traceparent) {
1461+
this._data.traceContext = tc;
1462+
} else {
1463+
delete this._data.traceContext;
1464+
}
1465+
return this;
1466+
}
1467+
1468+
/**
1469+
* Returns the trace context attached to the metadata, if any.
1470+
* @return W3C trace context carrier or undefined
1471+
*/
1472+
getTraceContext() {
1473+
return this._data.traceContext;
1474+
}
1475+
14451476
/**
14461477
* Returns metadata object
14471478
*
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { context, propagation, trace } from '@opentelemetry/api';
2+
3+
/**
4+
* Capture the currently-active OTEL trace context as a plain object
5+
* suitable for storing alongside metadata (ends up in the MongoDB oplog).
6+
* Returns undefined when no trace is active (e.g., OTEL not enabled,
7+
* or called outside a traced request).
8+
*/
9+
export function captureCurrentTraceContext():
10+
{ traceparent: string; tracestate?: string } | undefined {
11+
const ctx = context.active();
12+
const span = trace.getSpan(ctx);
13+
if (!span) {
14+
return undefined;
15+
}
16+
17+
const carrier: Record<string, string> = {};
18+
propagation.inject(ctx, carrier, {
19+
set: (c, k, v) => { c[k] = v; },
20+
});
21+
if (!carrier.traceparent) {
22+
return undefined;
23+
}
24+
25+
const out: { traceparent: string; tracestate?: string } = {
26+
traceparent: carrier.traceparent,
27+
};
28+
if (carrier.tracestate) {
29+
out.tracestate = carrier.tracestate;
30+
}
31+
return out;
32+
}

lib/storage/metadata/mongoclient/MongoClientInterface.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ErrorLike, reshapeExceptionError } from '../../../errorUtils';
1818
import errors, { ArsenalError, errorInstances } from '../../../errors';
1919
import BucketInfo, { BucketMetadata, Capabilities } from '../../../models/BucketInfo';
2020
import ObjectMD, { ObjectMDData } from '../../../models/ObjectMD';
21+
import { captureCurrentTraceContext } from '../captureTraceContext';
2122
import * as jsutil from '../../../jsutil';
2223
import { ArsenalCallback, NestedOmit } from '../../../types';
2324

@@ -1361,6 +1362,12 @@ class MongoClientInterface {
13611362
cb: ArsenalCallback<string | void>,
13621363
): void {
13631364
MongoUtils.serialize(objVal);
1365+
const tc = captureCurrentTraceContext();
1366+
if (tc) {
1367+
objVal.traceContext = tc;
1368+
} else {
1369+
delete objVal.traceContext;
1370+
}
13641371
const c = this.getCollection<ObjectMetastoreDocument>(bucketName);
13651372
const _params = Object.assign({}, params);
13661373
return this.getBucketVFormat(bucketName, log, (err, vFormat?) => {
@@ -1635,6 +1642,15 @@ class MongoClientInterface {
16351642
const masterKey = formatMasterKey(objName, vFormat);
16361643
MongoUtils.serialize(objVal);
16371644
objVal.originOp = 's3:ObjectRemoved:Delete';
1645+
const tc = captureCurrentTraceContext();
1646+
if (tc) {
1647+
objVal.traceContext = tc;
1648+
} else {
1649+
// Clear any carried-forward trace context from the loaded
1650+
// ObjectMD so this repair write doesn't get wrongly linked
1651+
// to a previous operation's trace.
1652+
delete objVal.traceContext;
1653+
}
16381654
c.findOneAndReplace({
16391655
'_id': masterKey,
16401656
'value.isPHD': true,
@@ -2073,6 +2089,7 @@ class MongoClientInterface {
20732089
const obj = doc.value;
20742090
const objMetadata = new ObjectMD(obj.value);
20752091
objMetadata.setOriginOp(originOp);
2092+
objMetadata.setTraceContext(captureCurrentTraceContext());
20762093
objMetadata.setDeleted(true);
20772094
return next(null, objMetadata.getValue());
20782095
}).catch(err => {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@azure/identity": "^4.13.0",
2525
"@azure/storage-blob": "^12.31.0",
2626
"@js-sdsl/ordered-set": "^4.4.2",
27+
"@opentelemetry/api": "^1.9.0",
2728
"@scality/hdclient": "^1.3.1",
2829
"@smithy/node-http-handler": "^4.3.0",
2930
"@smithy/protocol-http": "^5.3.5",

tests/unit/models/ObjectMD.spec.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,59 @@ describe('ObjectMD class setters/getters', () => {
328328
assert.deepStrictEqual(md.getOriginOp(), 'Copy');
329329
});
330330

331+
it('ObjectMD::set/getTraceContext with valid traceparent', () => {
332+
const tc = {
333+
traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
334+
tracestate: 'rojo=00f067aa0ba902b7',
335+
};
336+
md.setTraceContext(tc);
337+
assert.deepStrictEqual(md.getTraceContext(), tc);
338+
});
339+
340+
it('ObjectMD::setTraceContext with undefined clears any existing value', () => {
341+
md.setTraceContext({
342+
traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
343+
});
344+
md.setTraceContext(undefined);
345+
assert.strictEqual(md.getTraceContext(), undefined);
346+
assert.strictEqual(md.getValue().traceContext, undefined);
347+
});
348+
349+
it('ObjectMD::setTraceContext without traceparent clears any existing value', () => {
350+
md.setTraceContext({
351+
traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
352+
});
353+
md.setTraceContext({ tracestate: 'rojo=00f067aa0ba902b7' });
354+
assert.strictEqual(md.getTraceContext(), undefined);
355+
});
356+
357+
it('ObjectMD reconstructed from existing data clears stale trace context when setTraceContext(undefined) is called', () => {
358+
// Simulates the real hazard: an ObjectMD loaded from storage
359+
// already has a traceContext from a previous write. A new
360+
// operation without an active span must not inherit that stale
361+
// context into its own oplog entry.
362+
const stale = {
363+
traceparent: '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01',
364+
};
365+
const loaded = new ObjectMD(
366+
new ObjectMD().setTraceContext(stale).getValue(),
367+
);
368+
assert.deepStrictEqual(loaded.getTraceContext(), stale);
369+
loaded.setTraceContext(undefined);
370+
assert.strictEqual(loaded.getTraceContext(), undefined);
371+
assert.strictEqual(loaded.getValue().traceContext, undefined);
372+
});
373+
374+
it('ObjectMD::getValue serializes traceContext only when set', () => {
375+
assert.strictEqual(md.getValue().traceContext, undefined);
376+
md.setTraceContext({
377+
traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
378+
});
379+
assert.deepStrictEqual(md.getValue().traceContext, {
380+
traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
381+
});
382+
});
383+
331384
it('ObjectMD::set/getAmzRestore', () => {
332385
md.setAmzRestore({
333386
'ongoing-request': false,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use strict';
2+
3+
const assert = require('assert');
4+
5+
// Mock @opentelemetry/api so we can drive captureCurrentTraceContext
6+
// without needing a registered SDK or propagator. The api package is a
7+
// runtime dep of arsenal but we can stub the small surface it consumes.
8+
const mockActive = jest.fn();
9+
const mockGetSpan = jest.fn();
10+
const mockInject = jest.fn();
11+
12+
jest.mock('@opentelemetry/api', () => ({
13+
context: { active: mockActive },
14+
trace: { getSpan: mockGetSpan },
15+
propagation: { inject: mockInject },
16+
}));
17+
18+
const {
19+
captureCurrentTraceContext,
20+
} = require('../../../../lib/storage/metadata/captureTraceContext');
21+
22+
describe('captureCurrentTraceContext', () => {
23+
beforeEach(() => {
24+
mockActive.mockReset();
25+
mockGetSpan.mockReset();
26+
mockInject.mockReset();
27+
mockActive.mockReturnValue({ tag: 'mock-active-context' });
28+
});
29+
30+
it('returns undefined when no span is active', () => {
31+
mockGetSpan.mockReturnValue(undefined);
32+
assert.strictEqual(captureCurrentTraceContext(), undefined);
33+
// No injection should be attempted when there is no active span.
34+
assert.strictEqual(mockInject.mock.calls.length, 0);
35+
});
36+
37+
it('returns { traceparent } when active span yields a traceparent', () => {
38+
mockGetSpan.mockReturnValue({ /* opaque span object */ });
39+
mockInject.mockImplementation((ctx, carrier, setter) => {
40+
setter.set(carrier, 'traceparent',
41+
'00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01');
42+
});
43+
44+
assert.deepStrictEqual(captureCurrentTraceContext(), {
45+
traceparent:
46+
'00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
47+
});
48+
49+
// Verify inject was given the active context so propagation
50+
// sees the right state, not a freshly-constructed empty one.
51+
assert.strictEqual(mockInject.mock.calls.length, 1);
52+
const [ctxArg, carrierArg] = mockInject.mock.calls[0];
53+
assert.deepStrictEqual(ctxArg, { tag: 'mock-active-context' });
54+
assert.strictEqual(typeof carrierArg, 'object');
55+
});
56+
57+
it('returns both traceparent and tracestate when both are present', () => {
58+
mockGetSpan.mockReturnValue({});
59+
mockInject.mockImplementation((ctx, carrier, setter) => {
60+
setter.set(carrier, 'traceparent',
61+
'00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01');
62+
setter.set(carrier, 'tracestate', 'rojo=00f067aa0ba902b7');
63+
});
64+
65+
assert.deepStrictEqual(captureCurrentTraceContext(), {
66+
traceparent:
67+
'00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
68+
tracestate: 'rojo=00f067aa0ba902b7',
69+
});
70+
});
71+
72+
it('returns undefined when propagation injects no traceparent', () => {
73+
// Defensive case: a misbehaving propagator that exposes only
74+
// unrelated headers. We must not return a partial / invalid
75+
// trace context.
76+
mockGetSpan.mockReturnValue({});
77+
mockInject.mockImplementation((ctx, carrier, setter) => {
78+
setter.set(carrier, 'baggage', 'unrelated=true');
79+
});
80+
81+
assert.strictEqual(captureCurrentTraceContext(), undefined);
82+
});
83+
});

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2466,6 +2466,11 @@
24662466
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe"
24672467
integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==
24682468

2469+
"@opentelemetry/api@^1.9.0":
2470+
version "1.9.1"
2471+
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05"
2472+
integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==
2473+
24692474
"@pkgjs/parseargs@^0.11.0":
24702475
version "0.11.0"
24712476
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"

0 commit comments

Comments
 (0)