From 86191514c86e1dba0532c14ec43824ee912e7d08 Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Mon, 13 Apr 2026 16:35:04 +0200 Subject: [PATCH] feat: stamp W3C trace context on MongoDB metadata writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/models/ObjectMD.ts | 31 ++++ lib/storage/metadata/captureTraceContext.ts | 60 +++++++ .../mongoclient/MongoClientInterface.ts | 5 + package.json | 1 + tests/unit/models/ObjectMD.spec.js | 53 ++++++ .../metadata/captureTraceContext.spec.js | 83 +++++++++ .../metadata/mongoclient/delObject.spec.js | 100 +++++++++++ .../metadata/mongoclient/putObject.spec.js | 168 ++++++++++++++++++ .../metadata/mongoclient/repair.spec.js | 80 +++++++++ .../unit/storage/metadata/otelMockHelpers.js | 34 ++++ yarn.lock | 5 + 11 files changed, 620 insertions(+) create mode 100644 lib/storage/metadata/captureTraceContext.ts create mode 100644 tests/unit/storage/metadata/captureTraceContext.spec.js create mode 100644 tests/unit/storage/metadata/mongoclient/repair.spec.js create mode 100644 tests/unit/storage/metadata/otelMockHelpers.js diff --git a/lib/models/ObjectMD.ts b/lib/models/ObjectMD.ts index 1397ddef5..acaf138d5 100644 --- a/lib/models/ObjectMD.ts +++ b/lib/models/ObjectMD.ts @@ -1,5 +1,9 @@ import * as crypto from 'crypto'; import * as constants from '../constants'; +import { + applyTraceContext, + TraceContextCarrier, +} from '../storage/metadata/captureTraceContext'; import * as VersionIDUtils from '../versioning/VersionID'; import { VersioningConstants } from '../versioning/constants'; import ObjectMDLocation, { @@ -89,6 +93,10 @@ export type ObjectMDData = { replicationInfo: ReplicationInfo; dataStoreName: string; originOp: string; + traceContext?: { + traceparent?: string; + tracestate?: string; + }; microVersionId?: string; // Deletion flag // Used for keeping object metadata in the oplog event @@ -1442,6 +1450,29 @@ export default class ObjectMD { return this._data.originOp; } + /** + * Attach a W3C trace context to the metadata so it ends up in + * the MongoDB oplog and downstream consumers can continue the trace. + * Always reflects the current write: passing undefined (or a value + * without traceparent) clears any previously-set traceContext so that + * a stale context does not get carried forward from an existing + * ObjectMD loaded from storage. + * @param tc - W3C trace context carrier + * @return itself + */ + setTraceContext(tc?: TraceContextCarrier) { + applyTraceContext(this._data, tc); + return this; + } + + /** + * Returns the trace context attached to the metadata, if any. + * @return W3C trace context carrier or undefined + */ + getTraceContext() { + return this._data.traceContext; + } + /** * Returns metadata object * diff --git a/lib/storage/metadata/captureTraceContext.ts b/lib/storage/metadata/captureTraceContext.ts new file mode 100644 index 000000000..ebd62d06b --- /dev/null +++ b/lib/storage/metadata/captureTraceContext.ts @@ -0,0 +1,60 @@ +import { context, propagation, trace } from '@opentelemetry/api'; + +export interface TraceContextCarrier { + traceparent?: string; + tracestate?: string; +} + +/** + * Set or clear the `traceContext` field on a metadata-shaped object. + * Used by every site that stamps trace context onto a write — both raw + * ObjectMDData manipulation and the ObjectMD model setter — so the + * set/clear semantics live in exactly one place. Always reflects the + * current write: passing undefined (or a value without `traceparent`) + * clears any previously-set context so a stale value is not carried + * forward from a loaded ObjectMD. + * + * Generic over `data` so callers can use either an ObjectMD's `_data` + * or a raw `ObjectMDData` without type gymnastics. + */ +export function applyTraceContext( + data: { traceContext?: TraceContextCarrier }, + tc?: TraceContextCarrier, +): void { + if (tc && tc.traceparent) { + data.traceContext = tc; + } else { + delete data.traceContext; + } +} + +/** + * Capture the currently-active OTEL trace context as a plain object + * suitable for storing alongside metadata (ends up in the MongoDB oplog). + * Returns undefined when no trace is active (e.g., OTEL not enabled, + * or called outside a traced request). + */ +export function captureCurrentTraceContext(): + { traceparent: string; tracestate?: string } | undefined { + const ctx = context.active(); + const span = trace.getSpan(ctx); + if (!span) { + return undefined; + } + + const carrier: Record = {}; + propagation.inject(ctx, carrier, { + set: (c, k, v) => { c[k] = v; }, + }); + if (!carrier.traceparent) { + return undefined; + } + + const out: { traceparent: string; tracestate?: string } = { + traceparent: carrier.traceparent, + }; + if (carrier.tracestate) { + out.tracestate = carrier.tracestate; + } + return out; +} diff --git a/lib/storage/metadata/mongoclient/MongoClientInterface.ts b/lib/storage/metadata/mongoclient/MongoClientInterface.ts index fb7699434..13395f844 100644 --- a/lib/storage/metadata/mongoclient/MongoClientInterface.ts +++ b/lib/storage/metadata/mongoclient/MongoClientInterface.ts @@ -18,6 +18,7 @@ import { ErrorLike, reshapeExceptionError } from '../../../errorUtils'; import errors, { ArsenalError, errorInstances } from '../../../errors'; import BucketInfo, { BucketMetadata, Capabilities } from '../../../models/BucketInfo'; import ObjectMD, { ObjectMDData } from '../../../models/ObjectMD'; +import { applyTraceContext, captureCurrentTraceContext } from '../captureTraceContext'; import * as jsutil from '../../../jsutil'; import { ArsenalCallback, NestedOmit } from '../../../types'; @@ -1284,6 +1285,7 @@ class MongoClientInterface { const obj = doc.value; const objMetadata = new ObjectMD(obj); objMetadata.setOriginOp(params.originOp); + objMetadata.setTraceContext(captureCurrentTraceContext()); objMetadata.setDeleted(true); return next(null, objMetadata.getValue()); }).catch(err => { @@ -1361,6 +1363,7 @@ class MongoClientInterface { cb: ArsenalCallback, ): void { MongoUtils.serialize(objVal); + applyTraceContext(objVal, captureCurrentTraceContext()); const c = this.getCollection(bucketName); const _params = Object.assign({}, params); return this.getBucketVFormat(bucketName, log, (err, vFormat?) => { @@ -1635,6 +1638,7 @@ class MongoClientInterface { const masterKey = formatMasterKey(objName, vFormat); MongoUtils.serialize(objVal); objVal.originOp = 's3:ObjectRemoved:Delete'; + applyTraceContext(objVal, captureCurrentTraceContext()); c.findOneAndReplace({ '_id': masterKey, 'value.isPHD': true, @@ -2073,6 +2077,7 @@ class MongoClientInterface { const obj = doc.value; const objMetadata = new ObjectMD(obj.value); objMetadata.setOriginOp(originOp); + objMetadata.setTraceContext(captureCurrentTraceContext()); objMetadata.setDeleted(true); return next(null, objMetadata.getValue()); }).catch(err => { diff --git a/package.json b/package.json index 826861772..f7f9bcce2 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@azure/identity": "^4.13.0", "@azure/storage-blob": "^12.31.0", "@js-sdsl/ordered-set": "^4.4.2", + "@opentelemetry/api": "^1.9.0", "@scality/hdclient": "^1.3.2", "@smithy/node-http-handler": "^4.3.0", "@smithy/protocol-http": "^5.3.5", diff --git a/tests/unit/models/ObjectMD.spec.js b/tests/unit/models/ObjectMD.spec.js index 33c2e0736..69287f9c4 100644 --- a/tests/unit/models/ObjectMD.spec.js +++ b/tests/unit/models/ObjectMD.spec.js @@ -328,6 +328,59 @@ describe('ObjectMD class setters/getters', () => { assert.deepStrictEqual(md.getOriginOp(), 'Copy'); }); + it('ObjectMD::set/getTraceContext with valid traceparent', () => { + const tc = { + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + tracestate: 'rojo=00f067aa0ba902b7', + }; + md.setTraceContext(tc); + assert.deepStrictEqual(md.getTraceContext(), tc); + }); + + it('ObjectMD::setTraceContext with undefined clears any existing value', () => { + md.setTraceContext({ + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }); + md.setTraceContext(undefined); + assert.strictEqual(md.getTraceContext(), undefined); + assert.strictEqual(md.getValue().traceContext, undefined); + }); + + it('ObjectMD::setTraceContext without traceparent clears any existing value', () => { + md.setTraceContext({ + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }); + md.setTraceContext({ tracestate: 'rojo=00f067aa0ba902b7' }); + assert.strictEqual(md.getTraceContext(), undefined); + }); + + it('ObjectMD reconstructed from existing data clears stale trace context when setTraceContext(undefined) is called', () => { + // Simulates the real hazard: an ObjectMD loaded from storage + // already has a traceContext from a previous write. A new + // operation without an active span must not inherit that stale + // context into its own oplog entry. + const stale = { + traceparent: '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + }; + const loaded = new ObjectMD( + new ObjectMD().setTraceContext(stale).getValue(), + ); + assert.deepStrictEqual(loaded.getTraceContext(), stale); + loaded.setTraceContext(undefined); + assert.strictEqual(loaded.getTraceContext(), undefined); + assert.strictEqual(loaded.getValue().traceContext, undefined); + }); + + it('ObjectMD::getValue serializes traceContext only when set', () => { + assert.strictEqual(md.getValue().traceContext, undefined); + md.setTraceContext({ + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }); + assert.deepStrictEqual(md.getValue().traceContext, { + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }); + }); + it('ObjectMD::set/getAmzRestore', () => { md.setAmzRestore({ 'ongoing-request': false, diff --git a/tests/unit/storage/metadata/captureTraceContext.spec.js b/tests/unit/storage/metadata/captureTraceContext.spec.js new file mode 100644 index 000000000..6bcb43e4b --- /dev/null +++ b/tests/unit/storage/metadata/captureTraceContext.spec.js @@ -0,0 +1,83 @@ +'use strict'; + +const assert = require('assert'); + +// Mock @opentelemetry/api so we can drive captureCurrentTraceContext +// without needing a registered SDK or propagator. The api package is a +// runtime dep of arsenal but we can stub the small surface it consumes. +const mockActive = jest.fn(); +const mockGetSpan = jest.fn(); +const mockInject = jest.fn(); + +jest.mock('@opentelemetry/api', () => ({ + context: { active: mockActive }, + trace: { getSpan: mockGetSpan }, + propagation: { inject: mockInject }, +})); + +const { + captureCurrentTraceContext, +} = require('../../../../lib/storage/metadata/captureTraceContext'); + +describe('captureCurrentTraceContext', () => { + beforeEach(() => { + mockActive.mockReset(); + mockGetSpan.mockReset(); + mockInject.mockReset(); + mockActive.mockReturnValue({ tag: 'mock-active-context' }); + }); + + it('returns undefined when no span is active', () => { + mockGetSpan.mockReturnValue(undefined); + assert.strictEqual(captureCurrentTraceContext(), undefined); + // No injection should be attempted when there is no active span. + assert.strictEqual(mockInject.mock.calls.length, 0); + }); + + it('returns { traceparent } when active span yields a traceparent', () => { + mockGetSpan.mockReturnValue({ /* opaque span object */ }); + mockInject.mockImplementation((ctx, carrier, setter) => { + setter.set(carrier, 'traceparent', + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'); + }); + + assert.deepStrictEqual(captureCurrentTraceContext(), { + traceparent: + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }); + + // Verify inject was given the active context so propagation + // sees the right state, not a freshly-constructed empty one. + assert.strictEqual(mockInject.mock.calls.length, 1); + const [ctxArg, carrierArg] = mockInject.mock.calls[0]; + assert.deepStrictEqual(ctxArg, { tag: 'mock-active-context' }); + assert.strictEqual(typeof carrierArg, 'object'); + }); + + it('returns both traceparent and tracestate when both are present', () => { + mockGetSpan.mockReturnValue({}); + mockInject.mockImplementation((ctx, carrier, setter) => { + setter.set(carrier, 'traceparent', + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'); + setter.set(carrier, 'tracestate', 'rojo=00f067aa0ba902b7'); + }); + + assert.deepStrictEqual(captureCurrentTraceContext(), { + traceparent: + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + tracestate: 'rojo=00f067aa0ba902b7', + }); + }); + + it('returns undefined when propagation injects no traceparent', () => { + // Defensive case: a misbehaving propagator that exposes only + // unrelated headers. We must not return a partial / invalid + // trace context. + mockGetSpan.mockReturnValue({}); + mockInject.mockImplementation((ctx, carrier, setter) => { + setter.set(carrier, 'baggage', 'unrelated=true'); + }); + + assert.strictEqual(captureCurrentTraceContext(), undefined); + }); +}); diff --git a/tests/unit/storage/metadata/mongoclient/delObject.spec.js b/tests/unit/storage/metadata/mongoclient/delObject.spec.js index 993e7f5e3..f2acbcff5 100644 --- a/tests/unit/storage/metadata/mongoclient/delObject.spec.js +++ b/tests/unit/storage/metadata/mongoclient/delObject.spec.js @@ -1,3 +1,16 @@ +// Mock @opentelemetry/api so the trace-context tests below can drive +// the active-span state without bringing up an OTEL SDK. Existing +// delObject tests don't touch OTEL; with these defaults the mocks +// behave like "no active span" and applyTraceContext is a no-op. +const mockOtelActive = jest.fn(); +const mockOtelGetSpan = jest.fn(); +const mockOtelInject = jest.fn(); +jest.mock('@opentelemetry/api', () => ({ + context: { active: mockOtelActive }, + trace: { getSpan: mockOtelGetSpan }, + propagation: { inject: mockOtelInject }, +})); + const assert = require('assert'); const werelogs = require('werelogs'); const logger = new werelogs.Logger('MongoClientInterface', 'debug', 'debug'); @@ -6,6 +19,13 @@ const sinon = require('sinon'); const MongoClientInterface = require('../../../../../lib/storage/metadata/mongoclient/MongoClientInterface'); const utils = require('../../../../../lib/storage/metadata/mongoclient/utils'); +const { makeOtelHelpers } = require('../otelMockHelpers'); + +const otel = makeOtelHelpers({ + active: mockOtelActive, + getSpan: mockOtelGetSpan, + inject: mockOtelInject, +}); const objMD = { _id: 'example-object', @@ -283,3 +303,83 @@ describe('MongoClientInterface:delObject', () => { }); }); }); + +describe('MongoClientInterface:internalDeleteObject trace-context plumbing', () => { + let client; + let collection; + let bulkWriteArg; + + beforeAll(() => { + client = new MongoClientInterface({}); + }); + + beforeEach(() => { + otel.resetMocks(); + bulkWriteArg = null; + collection = { + findOneAndUpdate: sinon.stub().callsFake(() => Promise.resolve({ + value: { + _id: 'example', + value: { + // Loaded from storage with a stale trace context — + // exercises the carry-forward guard on the + // setTraceContext path. + key: 'example', + traceContext: { traceparent: 'stale-prior-trace' }, + }, + }, + })), + bulkWrite: sinon.stub().callsFake(ops => { + bulkWriteArg = ops; + return Promise.resolve({ ok: 1, deletedCount: 1 }); + }), + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('stamps the active traceContext on the delete tombstone', done => { + otel.activateSpan(); + client.internalDeleteObject( + collection, 'bucket', 'example', {}, 's3:ObjectRemoved:Delete', + logger, err => { + assert.ifError(err); + assert.ok(bulkWriteArg, 'bulkWrite was called'); + const written = bulkWriteArg[0].updateOne.update.$set.value; + assert.deepStrictEqual( + written.traceContext, + { traceparent: otel.TRACEPARENT }, + ); + done(); + }, + ); + }); + + it('clears the loaded objVal\'s stale traceContext when no span is active', done => { + otel.deactivateSpan(); + client.internalDeleteObject( + collection, 'bucket', 'example', {}, 's3:ObjectRemoved:Delete', + logger, err => { + assert.ifError(err); + const written = bulkWriteArg[0].updateOne.update.$set.value; + assert.strictEqual(written.traceContext, undefined); + done(); + }, + ); + }); + + it('returns NoSuchKey when target object is not found', done => { + collection.findOneAndUpdate = sinon.stub() + .callsFake(() => Promise.resolve({ value: null })); + client.internalDeleteObject( + collection, 'bucket', 'missing', {}, 's3:ObjectRemoved:Delete', + logger, err => { + assert.ok(err); + assert(err.is.NoSuchKey); + done(); + }, + ); + }); +}); diff --git a/tests/unit/storage/metadata/mongoclient/putObject.spec.js b/tests/unit/storage/metadata/mongoclient/putObject.spec.js index d9c59466e..04cb9dfd6 100644 --- a/tests/unit/storage/metadata/mongoclient/putObject.spec.js +++ b/tests/unit/storage/metadata/mongoclient/putObject.spec.js @@ -1,3 +1,16 @@ +// Mock @opentelemetry/api so the trace-context tests below can drive +// the active-span state without bringing up an OTEL SDK. Existing +// putObject tests don't touch OTEL; with these defaults the mocks +// behave like "no active span" and applyTraceContext is a no-op. +const mockOtelActive = jest.fn(); +const mockOtelGetSpan = jest.fn(); +const mockOtelInject = jest.fn(); +jest.mock('@opentelemetry/api', () => ({ + context: { active: mockOtelActive }, + trace: { getSpan: mockOtelGetSpan }, + propagation: { inject: mockOtelInject }, +})); + const assert = require('assert'); const werelogs = require('werelogs'); const logger = new werelogs.Logger('MongoClientInterface', 'debug', 'debug'); @@ -10,8 +23,14 @@ const { createClient, createBucket } = require('./MongoClientInterface.spec'); const { BucketVersioningKeyFormat } = require('../../../../../lib/versioning/constants').VersioningConstants; const { default: ObjectMD } = require('../../../../../lib/models/ObjectMD'); const DummyRequestLogger = require('../../../helpers').DummyRequestLogger; +const { makeOtelHelpers } = require('../otelMockHelpers'); const log = new DummyRequestLogger(); +const otel = makeOtelHelpers({ + active: mockOtelActive, + getSpan: mockOtelGetSpan, + inject: mockOtelInject, +}); describe('MongoClientInterface:putObject', () => { let client; @@ -624,3 +643,152 @@ describe('MongoClientInterface:putObjectNoVer', () => { }, false); }); }); + +describe('MongoClientInterface:putObject trace-context plumbing', () => { + let client; + + beforeAll(() => { + client = new MongoClientInterface({}); + }); + + beforeEach(() => { + otel.resetMocks(); + sinon.stub(utils, 'formatMasterKey').callsFake(() => 'master-key'); + sinon.stub(utils, 'formatVersionKey').callsFake(() => 'version-key'); + sinon.stub(client, 'getCollection').callsFake(() => ({})); + sinon.stub(client, 'getBucketVFormat') + .callsFake((b, l, cb) => cb(null, 'v0')); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('stamps traceContext on the write when a span is active', done => { + otel.activateSpan(); + const seen = {}; + sinon.stub(client, 'putObjectNoVer') + .callsFake((_c, _b, _o, objVal, _p, _l, cb) => { + Object.assign(seen, objVal); + cb(); + }); + + client.putObject('bucket', 'example', { key: 'example' }, {}, log, err => { + assert.ifError(err); + assert.deepStrictEqual( + seen.traceContext, + { traceparent: otel.TRACEPARENT }, + ); + done(); + }); + }); + + it('omits traceContext when no span is active', done => { + otel.deactivateSpan(); + const seen = {}; + sinon.stub(client, 'putObjectNoVer') + .callsFake((_c, _b, _o, objVal, _p, _l, cb) => { + Object.assign(seen, objVal); + cb(); + }); + + client.putObject('bucket', 'example', { key: 'x' }, {}, log, err => { + assert.ifError(err); + assert.strictEqual(seen.traceContext, undefined); + done(); + }); + }); + + it('clears stale traceContext from a loaded objVal when no span is active', done => { + // Regression: an objVal loaded from storage may already carry a + // previous write's trace context. Without explicit clearing the + // next write would inherit the stale value. + otel.deactivateSpan(); + const seen = {}; + sinon.stub(client, 'putObjectNoVer') + .callsFake((_c, _b, _o, objVal, _p, _l, cb) => { + Object.assign(seen, objVal); + cb(); + }); + + const objVal = { + key: 'example', + traceContext: { traceparent: 'stale-from-prior-write' }, + }; + client.putObject('bucket', 'example', objVal, {}, log, err => { + assert.ifError(err); + assert.strictEqual(seen.traceContext, undefined); + done(); + }); + }); +}); + +// putObjectNoVerWithOplogUpdate is reached on the archived-object replace +// path (cloudserver sets params.needOplogUpdate with originOp +// 's3:ReplaceArchivedObject'). It bulkWrites two oplog entries: a +// tombstone of the loaded existing object (consumed by downstream +// cleanup workers) and the new value. Both need traceContext so +// consumers reading either oplog event can correlate to the originating +// S3 request. +describe('MongoClientInterface:putObjectNoVerWithOplogUpdate trace-context plumbing', () => { + let client; + let collection; + let bulkWriteArg; + + beforeAll(() => { + client = new MongoClientInterface({}); + }); + + beforeEach(() => { + otel.resetMocks(); + bulkWriteArg = null; + collection = { + findOneAndUpdate: sinon.stub().callsFake(() => Promise.resolve({ + value: { + key: 'existing', + traceContext: { traceparent: 'stale-prior-trace' }, + }, + })), + bulkWrite: sinon.stub().callsFake(ops => { + bulkWriteArg = ops; + return Promise.resolve({ ok: 1 }); + }), + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('stamps traceContext on the loaded-object tombstone when a span is active', done => { + otel.activateSpan(); + client.putObjectNoVerWithOplogUpdate( + collection, 'bucket', 'example', { key: 'replacement' }, + { vFormat: 'v0', originOp: 's3:ReplaceArchivedObject' }, + log, err => { + assert.ifError(err); + assert.ok(bulkWriteArg, 'bulkWrite was called'); + const tombstone = bulkWriteArg[0].updateOne.update.$set.value; + assert.deepStrictEqual( + tombstone.traceContext, + { traceparent: otel.TRACEPARENT }, + ); + done(); + }, + ); + }); + + it('clears stale traceContext on the tombstone when no span is active', done => { + otel.deactivateSpan(); + client.putObjectNoVerWithOplogUpdate( + collection, 'bucket', 'example', { key: 'replacement' }, + { vFormat: 'v0', originOp: 's3:ReplaceArchivedObject' }, + log, err => { + assert.ifError(err); + const tombstone = bulkWriteArg[0].updateOne.update.$set.value; + assert.strictEqual(tombstone.traceContext, undefined); + done(); + }, + ); + }); +}); diff --git a/tests/unit/storage/metadata/mongoclient/repair.spec.js b/tests/unit/storage/metadata/mongoclient/repair.spec.js new file mode 100644 index 000000000..775cdaedd --- /dev/null +++ b/tests/unit/storage/metadata/mongoclient/repair.spec.js @@ -0,0 +1,80 @@ +// Mock @opentelemetry/api so the trace-context tests can drive the +// active-span state without bringing up an OTEL SDK. +const mockOtelActive = jest.fn(); +const mockOtelGetSpan = jest.fn(); +const mockOtelInject = jest.fn(); +jest.mock('@opentelemetry/api', () => ({ + context: { active: mockOtelActive }, + trace: { getSpan: mockOtelGetSpan }, + propagation: { inject: mockOtelInject }, +})); + +const assert = require('assert'); +const werelogs = require('werelogs'); +const logger = new werelogs.Logger('MongoClientInterface', 'debug', 'debug'); +const sinon = require('sinon'); +const MongoClientInterface = + require('../../../../../lib/storage/metadata/mongoclient/MongoClientInterface'); +const { makeOtelHelpers } = require('../otelMockHelpers'); + +const otel = makeOtelHelpers({ + active: mockOtelActive, + getSpan: mockOtelGetSpan, + inject: mockOtelInject, +}); + +describe('MongoClientInterface:repair trace-context plumbing', () => { + let client; + let collection; + let captured; + + beforeAll(() => { + client = new MongoClientInterface({}); + }); + + beforeEach(() => { + otel.resetMocks(); + captured = null; + collection = { + findOneAndReplace: sinon.stub().callsFake((_filter, doc) => { + captured = doc; + return Promise.resolve({ ok: 1, value: doc }); + }), + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('stamps traceContext on the repair write when a span is active', done => { + otel.activateSpan(); + client.repair( + collection, 'bucket', 'example', { key: 'example' }, + { versionId: 'v1' }, 'v0', logger, err => { + assert.ifError(err); + assert.deepStrictEqual( + captured.value.traceContext, + { traceparent: otel.TRACEPARENT }, + ); + done(); + }, + ); + }); + + it('clears stale traceContext when no span is active', done => { + otel.deactivateSpan(); + const objVal = { + key: 'example', + traceContext: { traceparent: 'stale-prior-trace' }, + }; + client.repair( + collection, 'bucket', 'example', objVal, + { versionId: 'v1' }, 'v0', logger, err => { + assert.ifError(err); + assert.strictEqual(captured.value.traceContext, undefined); + done(); + }, + ); + }); +}); diff --git a/tests/unit/storage/metadata/otelMockHelpers.js b/tests/unit/storage/metadata/otelMockHelpers.js new file mode 100644 index 000000000..df7dc7a75 --- /dev/null +++ b/tests/unit/storage/metadata/otelMockHelpers.js @@ -0,0 +1,34 @@ +'use strict'; + +// Shared OTEL mock plumbing for the trace-context tests scattered across +// the per-method MongoClientInterface specs. Each spec file declares its +// own `mockOtel*` jest.fn()s at the top and passes them into +// makeOtelHelpers() — jest hoists `jest.mock(...)` factories above local +// requires, so the mocks themselves can't live here. + +const TRACEPARENT = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; + +function makeOtelHelpers(mocks) { + return { + TRACEPARENT, + activateSpan(traceparent = TRACEPARENT) { + mocks.active.mockReturnValue({ tag: 'mock-active-context' }); + mocks.getSpan.mockReturnValue({ /* opaque span */ }); + mocks.inject.mockImplementation((ctx, carrier, setter) => { + setter.set(carrier, 'traceparent', traceparent); + }); + }, + deactivateSpan() { + mocks.active.mockReturnValue({}); + mocks.getSpan.mockReturnValue(undefined); + mocks.inject.mockImplementation(() => {}); + }, + resetMocks() { + mocks.active.mockReset(); + mocks.getSpan.mockReset(); + mocks.inject.mockReset(); + }, + }; +} + +module.exports = { TRACEPARENT, makeOtelHelpers }; diff --git a/yarn.lock b/yarn.lock index 0208ef641..e2779d118 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2466,6 +2466,11 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== +"@opentelemetry/api@^1.9.0": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05" + integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"