From 0e2111aa24a05d3b3c454252dc524b0e01d67f6c Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Tue, 5 May 2026 18:56:43 +0200 Subject: [PATCH 1/4] feat: add OpenTelemetry tracing with trust boundaries and probe filtering Behind ENABLE_OTEL=true: - NodeSDK with OTLP/HTTP exporter and ParentBased + ratio sampler so we honor upstream NGINX/Beyla sampling decisions instead of re-sampling them away. - HTTP instrumentation with two hooks: - ignoreIncomingRequestHook drops spans on probe / scrape paths (/live, /ready, /_/healthcheck, /_/healthcheck/deep, /metrics) and OPTIONS preflight. - requestHook strips traceparent/tracestate on outbound requests to hosts not in TRUSTED_HOSTS, so trace IDs do not leak to external destinations. The client span is preserved (we still observe the call) and tagged scality.trace.suppressed=true. - buildTrustedHosts derives the allowlist from cloudserver Config (vaultd, dataClient, metadataClient, bucketd, KMIP, KMS, scuba, utapi, mongodb, hdclient/sproxyd connectors from locationConfig, PUSH/MANAGEMENT_ENDPOINT env vars, plus loopback). A unit test asserts every Config host shape is covered so the derivation stays honest as new backends land. - shutdownOtel() helper for the server's cleanUp() to await the exporter flush before process.exit so in-flight traces are not lost on SIGTERM. - mongodb auto-instrumentation tuned for low cardinality; ioredis enabled with requireParentSpan; fs / redis (v2/v3/v4) / aws-sdk disabled. When ENABLE_OTEL is unset the SDK and @opentelemetry/* packages are not loaded at all - zero overhead off the OTEL path. Issue: CLDSRV-884 --- index.js | 5 + lib/tracing/healthPaths.js | 18 + lib/tracing/index.js | 108 +++++ lib/tracing/trustedHosts.js | 100 ++++ package.json | 11 +- tests/unit/lib/tracing/healthPaths.spec.js | 30 ++ tests/unit/lib/tracing/init.spec.js | 160 ++++++ tests/unit/lib/tracing/trustedHosts.spec.js | 266 ++++++++++ yarn.lock | 508 +++++++++++++++++++- 9 files changed, 1201 insertions(+), 5 deletions(-) create mode 100644 lib/tracing/healthPaths.js create mode 100644 lib/tracing/index.js create mode 100644 lib/tracing/trustedHosts.js create mode 100644 tests/unit/lib/tracing/healthPaths.spec.js create mode 100644 tests/unit/lib/tracing/init.spec.js create mode 100644 tests/unit/lib/tracing/trustedHosts.spec.js diff --git a/index.js b/index.js index f5fa36c2e2..f1d1d18c3f 100644 --- a/index.js +++ b/index.js @@ -7,4 +7,9 @@ require('werelogs').stderrUtils.catchAndTimestampStderr( require('cluster').isPrimary ? 1 : null, ); +// Start tracing before requiring anything that hooks into HTTP, MongoDB, +// or ioredis — instrumentation patches modules on require, so anything +// loaded earlier than init() would run unpatched. +require('./lib/tracing').init(); + require('./lib/server.js')(); diff --git a/lib/tracing/healthPaths.js b/lib/tracing/healthPaths.js new file mode 100644 index 0000000000..340c58c5ad --- /dev/null +++ b/lib/tracing/healthPaths.js @@ -0,0 +1,18 @@ +'use strict'; + +// Probe + scrape paths that should never produce a span. Filtered at +// ingest (not at the trace backend) because probe rate × pod count × +// always-on sampling overwhelms the exporter and storage with traffic +// nobody queries. +const HEALTH_PATHS = new Set(['/live', '/ready', '/_/healthcheck', '/_/healthcheck/deep', '/metrics']); + +function isHealthPath(url) { + if (typeof url !== 'string' || url.length === 0) { + return false; + } + const qIdx = url.indexOf('?'); + const path = qIdx === -1 ? url : url.slice(0, qIdx); + return HEALTH_PATHS.has(path); +} + +module.exports = { isHealthPath }; diff --git a/lib/tracing/index.js b/lib/tracing/index.js new file mode 100644 index 0000000000..abd7ce0315 --- /dev/null +++ b/lib/tracing/index.js @@ -0,0 +1,108 @@ +'use strict'; + +const { buildTrustedHosts, makeRequestHook } = require('./trustedHosts'); +const { isHealthPath } = require('./healthPaths'); + +let sdk = null; + +function isEnabled() { + return process.env.ENABLE_OTEL === 'true'; +} + +function init() { + if (!isEnabled() || sdk) { + return; + } + + const endpoint = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT; + if (!endpoint) { + throw new Error('ENABLE_OTEL=true but OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is unset'); + } + + const { diag, DiagConsoleLogger, DiagLogLevel } = require('@opentelemetry/api'); + const { NodeSDK } = require('@opentelemetry/sdk-node'); + const { resourceFromAttributes } = require('@opentelemetry/resources'); + const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); + const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http'); + const { IORedisInstrumentation } = require('@opentelemetry/instrumentation-ioredis'); + const { MongoDBInstrumentation } = require('@opentelemetry/instrumentation-mongodb'); + const { ParentBasedSampler, TraceIdRatioBasedSampler } = require('@opentelemetry/sdk-trace-base'); + const { version } = require('../../package.json'); + const { config } = require('../Config'); + + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN); + + const parsedRatio = parseFloat(process.env.OTEL_SAMPLING_RATIO); + const samplingRatio = Number.isFinite(parsedRatio) ? parsedRatio : 0.01; + + const trustedHosts = buildTrustedHosts(config); + + const ignoreIncomingRequestHook = req => req.method === 'OPTIONS' || isHealthPath(req.url); + + sdk = new NodeSDK({ + resource: resourceFromAttributes({ + 'service.name': process.env.OTEL_SERVICE_NAME || 'cloudserver', + 'service.version': process.env.OTEL_SERVICE_VERSION || version, + 'service.namespace': process.env.OTEL_SERVICE_NAMESPACE || 'scality', + }), + traceExporter: new OTLPTraceExporter({ url: endpoint }), + logRecordProcessors: [], + metricReaders: [], + spanLimits: { + attributeValueLengthLimit: 4096, + attributeCountLimit: 128, + eventCountLimit: 128, + linkCountLimit: 128, + }, + sampler: new ParentBasedSampler({ + root: new TraceIdRatioBasedSampler(samplingRatio), + }), + instrumentations: [ + new HttpInstrumentation({ + ignoreIncomingRequestHook, + requestHook: makeRequestHook(trustedHosts), + }), + new IORedisInstrumentation({ requireParentSpan: true }), + // Mask leaf values in db.statement so query shape is captured + // without user data (object keys, filter values) flowing to + // the trace backend. + new MongoDBInstrumentation({ enhancedDatabaseReporting: false }), + ], + }); + + sdk.start(); +} + +// Cap the flush window. The BatchSpanProcessor's default 30s export +// timeout would otherwise block process.exit, and Kubernetes' default +// terminationGracePeriodSeconds is also 30s — we'd get SIGKILL'd +// before the flush ever completed. +const SHUTDOWN_DEADLINE_MS = 5000; + +async function close() { + // Capture + clear before awaiting so concurrent callers (SIGTERM + // during an uncaught-exception flow, for example) don't both call + // sdk.shutdown() — the SDK doesn't guarantee idempotent shutdown. + const local = sdk; + if (!local) { + return; + } + sdk = null; + try { + await Promise.race([ + local.shutdown(), + // .unref() so the timer doesn't pin the event loop open + // when sdk.shutdown() resolves first. + new Promise(resolve => { + setTimeout(resolve, SHUTDOWN_DEADLINE_MS).unref(); + }), + ]); + } catch (err) { + // Loggers may already be torn down at this point in shutdown; + // log to stderr directly. + // eslint-disable-next-line no-console + console.error('tracing close failed', err); + } +} + +module.exports = { init, close, isEnabled }; diff --git a/lib/tracing/trustedHosts.js b/lib/tracing/trustedHosts.js new file mode 100644 index 0000000000..870cdb1f07 --- /dev/null +++ b/lib/tracing/trustedHosts.js @@ -0,0 +1,100 @@ +'use strict'; + +function extractHost(s) { + if (typeof s !== 'string' || s.length === 0) { + return undefined; + } + if (s.includes('://')) { + try { + return new URL(s).hostname.toLowerCase(); + } catch { + // fall through to plain host:port parsing + } + } + return s.split(':')[0].toLowerCase(); +} + +// On outbound requests to hosts outside `trustedHosts`, strip +// traceparent/tracestate and tag the client span as suppressed — +// preserve the span (we still want to observe the call) without +// leaking trace IDs to external destinations. +function makeRequestHook(trustedHosts) { + return function requestHook(span, request) { + // IncomingMessage (inbound server spans) doesn't expose + // getHeader/removeHeader; only ClientRequest does. + if (!request || typeof request.getHeader !== 'function') { + return; + } + const host = extractHost((request.getHeader('host') || '').toString()); + if (trustedHosts.has(host)) { + return; + } + if (typeof request.removeHeader === 'function') { + request.removeHeader('traceparent'); + request.removeHeader('tracestate'); + } + if (span && typeof span.setAttribute === 'function') { + span.setAttribute('scality.trace.suppressed', true); + } + }; +} + +// Derived from cloudserver's Config so it stays honest as new backends +// land. A unit test asserts the set against a fixture Config. +function buildTrustedHosts(config) { + const hosts = new Set(['localhost', '127.0.0.1', '::1']); + + const add = v => { + const h = extractHost(v); + if (h) { + hosts.add(h); + } + }; + + if (!config) { + return hosts; + } + + add(config.vaultd?.host); + add(config.dataClient?.host); + add(config.metadataClient?.host); + add(config.pfsClient?.host); + add(config.cdmi?.host); + add(config.scuba?.host); + add(config.utapi?.host); + add(config.localCache?.host); + add(config.managementAgent?.host); + add(config.backbeat?.host); + add(config.kmsAWS?.endpoint); + + config.bucketd?.bootstrap?.forEach(add); + + if (config.kmip?.transport) { + const transports = Array.isArray(config.kmip.transport) ? config.kmip.transport : [config.kmip.transport]; + transports.forEach(t => add(t?.tls?.host)); + } + + if (typeof config.mongodb?.replicaSetHosts === 'string') { + config.mongodb.replicaSetHosts.split(',').forEach(add); + } + + // Read directly from env; lib/management/index.js sources these + // there too, they don't flow through Config.js. + add(process.env.PUSH_ENDPOINT); + add(process.env.MANAGEMENT_ENDPOINT); + + // Only the two Scality-owned connector shapes are trusted. Every + // other locationType (aws_s3, azure, gcp, *-archive, dmf, file, ...) + // is a separate cluster or external cloud — those stay untrusted + // so trace context doesn't leak across cluster boundaries. + if (config.locationConstraints && typeof config.locationConstraints === 'object') { + for (const loc of Object.values(config.locationConstraints)) { + loc?.details?.connector?.hdclient?.bootstrap?.forEach(add); + loc?.details?.connector?.sproxyd?.bootstrap?.forEach(add); + } + } + + return hosts; +} + +module.exports = { extractHost, buildTrustedHosts, makeRequestHook }; diff --git a/package.json b/package.json index 8bbc285057..86e6f376e6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,14 @@ "@aws-sdk/signature-v4": "^3.374.0", "@azure/storage-blob": "^12.28.0", "@hapi/joi": "^17.1.1", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-trace-otlp-http": "~0.218.0", + "@opentelemetry/instrumentation-http": "~0.218.0", + "@opentelemetry/instrumentation-ioredis": "~0.64.0", + "@opentelemetry/instrumentation-mongodb": "~0.69.0", + "@opentelemetry/resources": "^2.7.0", + "@opentelemetry/sdk-node": "~0.218.0", + "@opentelemetry/sdk-trace-base": "^2.7.0", "@smithy/node-http-handler": "^3.0.0", "arsenal": "git+https://github.com/scality/Arsenal#8.4.1", "async": "2.6.4", @@ -91,7 +99,8 @@ "nan": "v2.22.0", "fast-xml-parser": "^5.5.6", "ts-morph/**/brace-expansion": "^5.0.5", - "ts-morph/**/picomatch": "^4.0.4" + "ts-morph/**/picomatch": "^4.0.4", + "@opentelemetry/api": "^1.9.0" }, "countAsyncSourcePaths": [ "lib/**/*.js", diff --git a/tests/unit/lib/tracing/healthPaths.spec.js b/tests/unit/lib/tracing/healthPaths.spec.js new file mode 100644 index 0000000000..45707d5fe5 --- /dev/null +++ b/tests/unit/lib/tracing/healthPaths.spec.js @@ -0,0 +1,30 @@ +'use strict'; + +const assert = require('assert'); + +const { isHealthPath } = require('../../../../lib/tracing/healthPaths'); + +describe('tracing.isHealthPath', () => { + it('matches the canonical probe and scrape paths', () => { + ['/live', '/ready', '/_/healthcheck', '/_/healthcheck/deep', '/metrics'].forEach(p => + assert.strictEqual(isHealthPath(p), true, p), + ); + }); + + it('matches when a query string is present', () => { + assert.strictEqual(isHealthPath('/live?token=x'), true); + assert.strictEqual(isHealthPath('/metrics?format=prom'), true); + }); + + it('does not match unrelated paths', () => { + ['/bucket/key', '/_/backbeat/data/bucket/key', '/livez', '/metrics/custom'].forEach(p => + assert.strictEqual(isHealthPath(p), false, p), + ); + }); + + it('returns false for non-string / empty input', () => { + assert.strictEqual(isHealthPath(undefined), false); + assert.strictEqual(isHealthPath(''), false); + assert.strictEqual(isHealthPath(42), false); + }); +}); diff --git a/tests/unit/lib/tracing/init.spec.js b/tests/unit/lib/tracing/init.spec.js new file mode 100644 index 0000000000..22c4eee503 --- /dev/null +++ b/tests/unit/lib/tracing/init.spec.js @@ -0,0 +1,160 @@ +'use strict'; + +const assert = require('assert'); + +const TRACING_PATH = '../../../../lib/tracing'; + +function freshTracing() { + delete require.cache[require.resolve(TRACING_PATH)]; + return require(TRACING_PATH); +} + +describe('tracing.isEnabled', () => { + let saved; + beforeEach(() => { + saved = process.env.ENABLE_OTEL; + }); + afterEach(() => { + if (saved === undefined) { + delete process.env.ENABLE_OTEL; + } else { + process.env.ENABLE_OTEL = saved; + } + }); + + it('returns false when ENABLE_OTEL is unset', () => { + delete process.env.ENABLE_OTEL; + assert.strictEqual(freshTracing().isEnabled(), false); + }); + + it('returns false when ENABLE_OTEL is anything but "true"', () => { + process.env.ENABLE_OTEL = '1'; + assert.strictEqual(freshTracing().isEnabled(), false); + process.env.ENABLE_OTEL = 'TRUE'; + assert.strictEqual(freshTracing().isEnabled(), false); + process.env.ENABLE_OTEL = ''; + assert.strictEqual(freshTracing().isEnabled(), false); + }); + + it('returns true when ENABLE_OTEL is "true"', () => { + process.env.ENABLE_OTEL = 'true'; + assert.strictEqual(freshTracing().isEnabled(), true); + }); +}); + +describe('tracing.init', () => { + const savedEnv = { + ENABLE_OTEL: process.env.ENABLE_OTEL, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + }; + + afterEach(async () => { + // Tear down the SDK so the next test starts from a clean + // module-level state. We deliberately do NOT call + // trace/propagation/context.disable() here — those clear the + // process-global OTEL registry, which would also wipe the + // BasicTracerProvider that instrumentationSimple.spec.js set + // at module load. The next init() call will log a benign + // "duplicate registration" warning via diag; that is noise, + // not a functional issue. + try { + const t = require(TRACING_PATH); + await t.close(); + } catch (e) { + // ignore — close() before init is a no-op anyway + void e; + } + delete require.cache[require.resolve(TRACING_PATH)]; + + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + }); + + it('is a no-op when ENABLE_OTEL is unset', () => { + delete process.env.ENABLE_OTEL; + const t = freshTracing(); + assert.doesNotThrow(() => t.init()); + // close() should also short-circuit (no sdk to stop) + return t.close(); + }); + + it('throws when ENABLE_OTEL=true and the endpoint env var is unset', () => { + process.env.ENABLE_OTEL = 'true'; + delete process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT; + const t = freshTracing(); + assert.throws(() => t.init(), /OTEL_EXPORTER_OTLP_TRACES_ENDPOINT/); + }); + + it('boots the SDK when ENABLE_OTEL=true and an endpoint is set', () => { + process.env.ENABLE_OTEL = 'true'; + process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://otel-test.invalid:4318/v1/traces'; + const t = freshTracing(); + assert.doesNotThrow(() => t.init()); + assert.strictEqual(t.isEnabled(), true); + }); + + it('is idempotent — second init is a no-op', () => { + process.env.ENABLE_OTEL = 'true'; + process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://otel-test.invalid:4318/v1/traces'; + const t = freshTracing(); + t.init(); + // Second call must not throw and must not start a second SDK. + assert.doesNotThrow(() => t.init()); + }); +}); + +describe('tracing.close', () => { + const savedEnv = { + ENABLE_OTEL: process.env.ENABLE_OTEL, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + }; + + afterEach(async () => { + try { + const t = require(TRACING_PATH); + await t.close(); + } catch (e) { + void e; + } + delete require.cache[require.resolve(TRACING_PATH)]; + + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + }); + + it('resolves cleanly when init was never called', async () => { + delete process.env.ENABLE_OTEL; + const t = freshTracing(); + await t.close(); + }); + + it('resolves after init when enabled', async () => { + process.env.ENABLE_OTEL = 'true'; + process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://otel-test.invalid:4318/v1/traces'; + const t = freshTracing(); + t.init(); + await t.close(); + }); + + it('is safe under concurrent callers (race-safe)', async () => { + process.env.ENABLE_OTEL = 'true'; + process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://otel-test.invalid:4318/v1/traces'; + const t = freshTracing(); + t.init(); + // Capture two close() calls in flight at once. The race guard in + // tracing.close() clears the module-level sdk before awaiting, so + // sdk.shutdown() must run exactly once and both promises must + // resolve without throwing. + await Promise.all([t.close(), t.close()]); + }); +}); diff --git a/tests/unit/lib/tracing/trustedHosts.spec.js b/tests/unit/lib/tracing/trustedHosts.spec.js new file mode 100644 index 0000000000..e7b5a393db --- /dev/null +++ b/tests/unit/lib/tracing/trustedHosts.spec.js @@ -0,0 +1,266 @@ +'use strict'; + +const assert = require('assert'); + +const { buildTrustedHosts, extractHost, makeRequestHook } = require('../../../../lib/tracing/trustedHosts'); + +describe('tracing.extractHost', () => { + it('extracts hostname from a plain host string', () => { + assert.strictEqual(extractHost('example.com'), 'example.com'); + }); + + it('extracts hostname from a host:port string', () => { + assert.strictEqual(extractHost('example.com:8500'), 'example.com'); + }); + + it('lower-cases the extracted hostname', () => { + assert.strictEqual(extractHost('Example.COM:8500'), 'example.com'); + }); + + it('extracts hostname from an http URL', () => { + assert.strictEqual(extractHost('http://mongo.example.internal:27017/db'), 'mongo.example.internal'); + }); + + it('extracts hostname from an https URL', () => { + assert.strictEqual(extractHost('https://push.api.zenko.io/api/v1/instance'), 'push.api.zenko.io'); + }); + + it('returns undefined for empty / non-string input', () => { + assert.strictEqual(extractHost(undefined), undefined); + assert.strictEqual(extractHost(''), undefined); + assert.strictEqual(extractHost(42), undefined); + }); +}); + +describe('tracing.buildTrustedHosts', () => { + it('always contains loopback aliases', () => { + const hosts = buildTrustedHosts({}); + assert.ok(hosts.has('localhost')); + assert.ok(hosts.has('127.0.0.1')); + assert.ok(hosts.has('::1')); + }); + + it('handles a missing config gracefully', () => { + const hosts = buildTrustedHosts(); + assert.ok(hosts.has('localhost')); + assert.strictEqual(hosts.size, 3); + }); + + it('includes every host referenced in a full config', () => { + // Snapshot of every host-bearing config key cloudserver consults. + // If a new key is added without updating buildTrustedHosts, this + // test must fail. + const config = { + vaultd: { host: 'vaultd.zenko.svc.cluster.local' }, + dataClient: { host: 'data.zenko.svc.cluster.local' }, + metadataClient: { host: 'bucketd.zenko.svc.cluster.local' }, + pfsClient: { host: 'pfs.zenko.svc.cluster.local' }, + cdmi: { host: 'cdmi.zenko.svc.cluster.local' }, + bucketd: { + bootstrap: ['bucketd-a.zenko.svc.cluster.local:9000', 'bucketd-b.zenko.svc.cluster.local:9000'], + }, + kmip: { + transport: [ + { tls: { host: 'kmip-a.zenko.svc.cluster.local' } }, + { tls: { host: 'kmip-b.zenko.svc.cluster.local' } }, + ], + }, + kmsAWS: { endpoint: 'https://aws-kms.example.com' }, + scuba: { host: 'scuba.zenko.svc.cluster.local' }, + utapi: { host: 'utapi.zenko.svc.cluster.local' }, + localCache: { host: 'redis.zenko.svc.cluster.local' }, + managementAgent: { host: 'localhost' }, + backbeat: { host: 'backbeat.zenko.svc.cluster.local' }, + mongodb: { + replicaSetHosts: + 'mongo-0.zenko.svc.cluster.local:27017,' + + 'mongo-1.zenko.svc.cluster.local:27017,' + + 'mongo-2.zenko.svc.cluster.local:27017', + }, + }; + + const saved = { + PUSH_ENDPOINT: process.env.PUSH_ENDPOINT, + MANAGEMENT_ENDPOINT: process.env.MANAGEMENT_ENDPOINT, + }; + process.env.PUSH_ENDPOINT = 'https://push.api.zenko.io'; + process.env.MANAGEMENT_ENDPOINT = 'https://api.zenko.io'; + try { + const hosts = buildTrustedHosts(config); + const expected = [ + 'vaultd.zenko.svc.cluster.local', + 'data.zenko.svc.cluster.local', + 'bucketd.zenko.svc.cluster.local', + 'pfs.zenko.svc.cluster.local', + 'cdmi.zenko.svc.cluster.local', + 'bucketd-a.zenko.svc.cluster.local', + 'bucketd-b.zenko.svc.cluster.local', + 'kmip-a.zenko.svc.cluster.local', + 'kmip-b.zenko.svc.cluster.local', + 'aws-kms.example.com', + 'scuba.zenko.svc.cluster.local', + 'utapi.zenko.svc.cluster.local', + 'redis.zenko.svc.cluster.local', + 'backbeat.zenko.svc.cluster.local', + 'mongo-0.zenko.svc.cluster.local', + 'mongo-1.zenko.svc.cluster.local', + 'mongo-2.zenko.svc.cluster.local', + 'push.api.zenko.io', + 'api.zenko.io', + ]; + for (const h of expected) { + assert.ok(hosts.has(h), `expected trusted host ${h}`); + } + } finally { + if (saved.PUSH_ENDPOINT === undefined) { + delete process.env.PUSH_ENDPOINT; + } else { + process.env.PUSH_ENDPOINT = saved.PUSH_ENDPOINT; + } + if (saved.MANAGEMENT_ENDPOINT === undefined) { + delete process.env.MANAGEMENT_ENDPOINT; + } else { + process.env.MANAGEMENT_ENDPOINT = saved.MANAGEMENT_ENDPOINT; + } + } + }); + + it('tolerates a single-transport KMIP config object', () => { + const hosts = buildTrustedHosts({ + kmip: { transport: { tls: { host: 'kmip-single.example.com' } } }, + }); + assert.ok(hosts.has('kmip-single.example.com')); + }); + + it('ignores undefined host values', () => { + const hosts = buildTrustedHosts({ + vaultd: {}, + dataClient: {}, + metadataClient: {}, + }); + assert.strictEqual(hosts.size, 3); + }); + + it('includes hdclient and sproxyd connector hosts from locationConstraints, and only those', () => { + const hosts = buildTrustedHosts({ + locationConstraints: { + 'us-east-1': { + type: 'scality', + details: { + connector: { + hdclient: { + bootstrap: ['hdproxy-a.xcore.svc:18888', 'hdproxy-b.xcore.svc:18888'], + }, + }, + }, + }, + 'us-east-2': { + type: 'scality', + details: { + connector: { + sproxyd: { + bootstrap: ['sproxyd-a.ring.svc:81', 'sproxyd-b.ring.svc:81'], + }, + }, + }, + }, + 'aws-bucket': { + type: 'aws_s3', + details: { + awsEndpoint: 's3.us-west-2.amazonaws.com', + bucketName: 'external', + }, + }, + 'ring-remote': { + type: 'scality-ring-s3', + details: { + awsEndpoint: 's3.remote-ring.example.com', + bucketName: 'remote', + }, + }, + }, + }); + assert.ok(hosts.has('hdproxy-a.xcore.svc')); + assert.ok(hosts.has('hdproxy-b.xcore.svc')); + assert.ok(hosts.has('sproxyd-a.ring.svc')); + assert.ok(hosts.has('sproxyd-b.ring.svc')); + assert.ok(!hosts.has('s3.us-west-2.amazonaws.com')); + assert.ok(!hosts.has('s3.remote-ring.example.com')); + }); + + it('tolerates locationConstraints entries without a connector', () => { + assert.doesNotThrow(() => + buildTrustedHosts({ + locationConstraints: { + local: { type: 'file', details: {} }, + mem: { type: 'mem', details: {} }, + }, + }), + ); + }); +}); + +describe('tracing.makeRequestHook', () => { + const trusted = new Set(['trusted.example.com', 'localhost']); + const hook = makeRequestHook(trusted); + + function fakeClientRequest(host) { + const removed = []; + return { + _removed: removed, + getHeader(name) { + return name.toLowerCase() === 'host' ? host : undefined; + }, + removeHeader(name) { + removed.push(name); + }, + }; + } + + function fakeSpan() { + const attrs = {}; + return { + _attrs: attrs, + setAttribute(k, v) { + attrs[k] = v; + }, + }; + } + + it('is a no-op on inbound IncomingMessage (no getHeader method)', () => { + const span = fakeSpan(); + const inbound = { headers: { host: 'untrusted.example.com' } }; + assert.doesNotThrow(() => hook(span, inbound)); + assert.strictEqual(span._attrs['scality.trace.suppressed'], undefined); + }); + + it('is a no-op on undefined request', () => { + const span = fakeSpan(); + assert.doesNotThrow(() => hook(span, undefined)); + assert.strictEqual(span._attrs['scality.trace.suppressed'], undefined); + }); + + it('leaves trusted outbound requests untouched', () => { + const span = fakeSpan(); + const req = fakeClientRequest('trusted.example.com:8500'); + hook(span, req); + assert.deepStrictEqual(req._removed, []); + assert.strictEqual(span._attrs['scality.trace.suppressed'], undefined); + }); + + it('strips trace headers and tags span on untrusted outbound requests', () => { + const span = fakeSpan(); + const req = fakeClientRequest('external.example.com'); + hook(span, req); + assert.deepStrictEqual(req._removed.sort(), ['traceparent', 'tracestate']); + assert.strictEqual(span._attrs['scality.trace.suppressed'], true); + }); + + it('handles missing host header by treating as untrusted', () => { + const span = fakeSpan(); + const req = fakeClientRequest(undefined); + hook(span, req); + assert.deepStrictEqual(req._removed.sort(), ['traceparent', 'tracestate']); + assert.strictEqual(span._attrs['scality.trace.suppressed'], true); + }); +}); diff --git a/yarn.lock b/yarn.lock index 9ab9b0977f..0f203df099 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3221,6 +3221,24 @@ "@eslint/core" "^0.12.0" levn "^0.4.1" +"@grpc/grpc-js@^1.14.3": + version "1.14.3" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.3.tgz#4c9b817a900ae4020ddc28515ae4b52c78cfb8da" + integrity sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA== + dependencies: + "@grpc/proto-loader" "^0.8.0" + "@js-sdsl/ordered-map" "^4.4.2" + +"@grpc/proto-loader@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz#b6c324dd909c458a0e4aa9bfd3d69cf78a4b9bd8" + integrity sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.5.3" + yargs "^17.7.2" + "@hapi/address@^4.0.1": version "4.1.0" resolved "https://registry.yarnpkg.com/@hapi/address/-/address-4.1.0.tgz#d60c5c0d930e77456fdcde2598e77302e2955e1d" @@ -3395,6 +3413,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@js-sdsl/ordered-map@^4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" + integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== + "@js-sdsl/ordered-set@^4.4.2": version "4.4.2" resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-set/-/ordered-set-4.4.2.tgz#ab857eb63cf358b5a0f74fdd458b4601423779b7" @@ -3432,16 +3455,405 @@ dependencies: semver "^7.3.5" -"@opentelemetry/api@^1.4.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" - integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== +"@opentelemetry/api-logs@0.216.0": + version "0.216.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.216.0.tgz#7aa4b485ea2f2e21ffcb94120136c72f718c2eaf" + integrity sha512-KmGTgvxTJ0J01d4mOeX1wMV5NUTNf9HebIuOOGDfIn0a/IrnXIQbOnlylDyl9tkDv4h0DUpdI/GqCdLzfTkUXg== + dependencies: + "@opentelemetry/api" "^1.3.0" + +"@opentelemetry/api-logs@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz#7b9818e8dfdf1d3dcab88bfe4d6724f2f831f7ec" + integrity sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw== + dependencies: + "@opentelemetry/api" "^1.3.0" + +"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.4.0", "@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== + +"@opentelemetry/configuration@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/configuration/-/configuration-0.218.0.tgz#a5fdd50ec9cfa0adb3bb41202cb39f79c5f4e7d9" + integrity sha512-W8wIz7H2R1pufR5jfjb3gU2XkMpm2x/7b1RJcsuzvd70Il/rWWE+g5/Od7hQKrxRTSrTrOWlru101PWXz5I1EQ== + dependencies: + "@opentelemetry/core" "2.7.1" + yaml "^2.0.0" + +"@opentelemetry/context-async-hooks@2.7.1": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz#1555a6fb269596416d8c626fd020c3f2c38e071f" + integrity sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ== + +"@opentelemetry/core@2.7.1": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.7.1.tgz#162bfab46d6ff4da1bef240ea52e23a926b0fdbc" + integrity sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw== + dependencies: + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/exporter-logs-otlp-grpc@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.218.0.tgz#7e5a7e624074d6449590c851d58efe60096314b3" + integrity sha512-hoxrNH1l/Xy6F9WTJ5IK+6j1r9nQFlPOmrnTlhYHTySdunfXLmUCPv3bQtKYntxag9h3wLYBZQ2HI6FOx+BT2g== + dependencies: + "@grpc/grpc-js" "^1.14.3" + "@opentelemetry/core" "2.7.1" + "@opentelemetry/otlp-exporter-base" "0.218.0" + "@opentelemetry/otlp-grpc-exporter-base" "0.218.0" + "@opentelemetry/otlp-transformer" "0.218.0" + "@opentelemetry/sdk-logs" "0.218.0" + +"@opentelemetry/exporter-logs-otlp-http@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.218.0.tgz#0996cb45e0ebc6c7465445430a018edcac9871b4" + integrity sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw== + dependencies: + "@opentelemetry/api-logs" "0.218.0" + "@opentelemetry/core" "2.7.1" + "@opentelemetry/otlp-exporter-base" "0.218.0" + "@opentelemetry/otlp-transformer" "0.218.0" + "@opentelemetry/sdk-logs" "0.218.0" + +"@opentelemetry/exporter-logs-otlp-proto@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.218.0.tgz#e539720b2e145e85a7a4901a1eabb0b2e77990f8" + integrity sha512-1/noQNsp9gXD75HPzgjBrcF1+XTtry7pFAUfxVEJgg7mPv2AawKQuYkhMmJ8qjxz4Ubc3Y8bwvfxevXsKTq4cg== + dependencies: + "@opentelemetry/api-logs" "0.218.0" + "@opentelemetry/core" "2.7.1" + "@opentelemetry/otlp-exporter-base" "0.218.0" + "@opentelemetry/otlp-transformer" "0.218.0" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/sdk-logs" "0.218.0" + "@opentelemetry/sdk-trace-base" "2.7.1" + +"@opentelemetry/exporter-metrics-otlp-grpc@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.218.0.tgz#6d66c2638702489d063b8857741eee9cea1d21d6" + integrity sha512-YapQ9vNMX0NSZF6LK5pWAFfjpJleV2O9uYWfYGeb/5F1Kb9rPGK8tZDMJFa/sOksgdFuflDvYuA0B4qjDB4fjQ== + dependencies: + "@grpc/grpc-js" "^1.14.3" + "@opentelemetry/core" "2.7.1" + "@opentelemetry/exporter-metrics-otlp-http" "0.218.0" + "@opentelemetry/otlp-exporter-base" "0.218.0" + "@opentelemetry/otlp-grpc-exporter-base" "0.218.0" + "@opentelemetry/otlp-transformer" "0.218.0" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/sdk-metrics" "2.7.1" + +"@opentelemetry/exporter-metrics-otlp-http@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.218.0.tgz#c205581585d04c5106d1ca766e23632006e46a2c" + integrity sha512-bV7d2OuMpZu2+gAaxUAhzfZ0h3WVZk8ETQUEE3DNSntbTaMpuITjtm8I0rNyHFdm7Ax57K6ty7SgFXlBmOLIvQ== + dependencies: + "@opentelemetry/core" "2.7.1" + "@opentelemetry/otlp-exporter-base" "0.218.0" + "@opentelemetry/otlp-transformer" "0.218.0" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/sdk-metrics" "2.7.1" + +"@opentelemetry/exporter-metrics-otlp-proto@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.218.0.tgz#66642b8bb5e654ca7a5944a4f0b490814fc875fd" + integrity sha512-ubLddKjWULhla9YZRCj/rTBeppjJYE4e9w0icx5mTu3eFhWjQzbV75NYjXuIlEG+NJsBl6d+sTFw5Qu+oej4oQ== + dependencies: + "@opentelemetry/core" "2.7.1" + "@opentelemetry/exporter-metrics-otlp-http" "0.218.0" + "@opentelemetry/otlp-exporter-base" "0.218.0" + "@opentelemetry/otlp-transformer" "0.218.0" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/sdk-metrics" "2.7.1" + +"@opentelemetry/exporter-prometheus@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.218.0.tgz#41bddc97d34cd9d9993382b9c10fed19a8de63f4" + integrity sha512-RT5oEyu1kddZJ1vt7/BUo5wV+P7hpNAESsR3dUd3+8deHuX7gWNoCOZn+SfDT+hJHlIJ5h/AxiCLXIrutswDJg== + dependencies: + "@opentelemetry/core" "2.7.1" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/sdk-metrics" "2.7.1" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/exporter-trace-otlp-grpc@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.218.0.tgz#5d5532ab94d5514bec8aedc19ce3170b6381eed1" + integrity sha512-3fXxVQEj9TNAFaCi79JeFKfeLd0sDtInaR3gaZDVlzNSPHtz8PZuCV34JKWjD4XXzT20IdMe8IpX6mRVNDA4Tw== + dependencies: + "@grpc/grpc-js" "^1.14.3" + "@opentelemetry/core" "2.7.1" + "@opentelemetry/otlp-exporter-base" "0.218.0" + "@opentelemetry/otlp-grpc-exporter-base" "0.218.0" + "@opentelemetry/otlp-transformer" "0.218.0" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/sdk-trace-base" "2.7.1" + +"@opentelemetry/exporter-trace-otlp-http@0.218.0", "@opentelemetry/exporter-trace-otlp-http@~0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.218.0.tgz#36d6abf6d639b9ea861603c61f434751c0b1a0ea" + integrity sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw== + dependencies: + "@opentelemetry/core" "2.7.1" + "@opentelemetry/otlp-exporter-base" "0.218.0" + "@opentelemetry/otlp-transformer" "0.218.0" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/sdk-trace-base" "2.7.1" + +"@opentelemetry/exporter-trace-otlp-proto@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.218.0.tgz#de0b2b3545149dafedd2b948fb891f9bf962940c" + integrity sha512-r1Msf8SNLRmwh9J6XQ5uh82D7CdDWMNHnPB7LAVHjzut0TkSeKc5KcIvr4SvHvfk/xwN5gxC+VLKQ1k0o8PSPw== + dependencies: + "@opentelemetry/core" "2.7.1" + "@opentelemetry/otlp-exporter-base" "0.218.0" + "@opentelemetry/otlp-transformer" "0.218.0" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/sdk-trace-base" "2.7.1" + +"@opentelemetry/exporter-zipkin@2.7.1": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.7.1.tgz#3b79d223adc8c097ba3323e4de4ed8abb83c789e" + integrity sha512-mfsD9bKAxcKrh5+y08TPodvClBO0CznBE3p79YAGnO81WI4LrdsGA65T53e4iTSbCalW4WaUpkbeJcbpyIUHfg== + dependencies: + "@opentelemetry/core" "2.7.1" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/sdk-trace-base" "2.7.1" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/instrumentation-http@~0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.218.0.tgz#0b26b702a6288fa11bdea48ff4a3635385a7d587" + integrity sha512-x9djaqdzpT8WAboep1H9nCAQ1E+MMsm08TNfA02TqM3bNNddZeiim+E3KMWVQFaX6JpUy7V0nm/wfN/K2Em+Zw== + dependencies: + "@opentelemetry/core" "2.7.1" + "@opentelemetry/instrumentation" "0.218.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + forwarded-parse "2.1.2" + +"@opentelemetry/instrumentation-ioredis@~0.64.0": + version "0.64.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.64.0.tgz#b02a214263d5f3a6848f6911fe23f505ff6a9181" + integrity sha512-GQ36/amPdO1rVPXgrRZNnd6MktqwDcYalzpMRe9m55b3EwX4pazq8VB3qfTH67xboElqm/B9J1tBEnbQmcvaww== + dependencies: + "@opentelemetry/instrumentation" "^0.216.0" + "@opentelemetry/redis-common" "^0.38.3" + "@opentelemetry/semantic-conventions" "^1.33.0" + +"@opentelemetry/instrumentation-mongodb@~0.69.0": + version "0.69.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.69.0.tgz#b03010de06c816f973038165cfe4cea852b3d43f" + integrity sha512-kj8w2FN2/z0VIXMqcdAdJYtc0udH41Sb485jC7tLl0X4+OD3KLjyhjVoZOXH/gxp+N+BQY6SKgMNC0yi8nok9A== + dependencies: + "@opentelemetry/instrumentation" "^0.216.0" + "@opentelemetry/semantic-conventions" "^1.33.0" + +"@opentelemetry/instrumentation@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.218.0.tgz#fcceb4ffb45f99c0d292600769150fc5944dc3e9" + integrity sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg== + dependencies: + "@opentelemetry/api-logs" "0.218.0" + import-in-the-middle "^3.0.0" + require-in-the-middle "^8.0.0" + +"@opentelemetry/instrumentation@^0.216.0": + version "0.216.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.216.0.tgz#048777113d0cb7f6b6613025055fc0d2f822bf49" + integrity sha512-BrY0b2K81OLgwBcFxY2wKgPFhq4DpindT+S83++zquc5Rtb2SuYLMkujgDRWMgZQDz+OT+dfvPnMGADPuw4FDw== + dependencies: + "@opentelemetry/api-logs" "0.216.0" + import-in-the-middle "^3.0.0" + require-in-the-middle "^8.0.0" + +"@opentelemetry/otlp-exporter-base@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz#f33e3217ce568756baa132fabe30f0c9345db086" + integrity sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA== + dependencies: + "@opentelemetry/core" "2.7.1" + "@opentelemetry/otlp-transformer" "0.218.0" + +"@opentelemetry/otlp-grpc-exporter-base@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.218.0.tgz#dc5a1fec716245d84dc4167de9b1c607e07fb6a7" + integrity sha512-H/lCGJ536N98VpYJOaWTQOkv4Dx6TnmStK6Rqfu1W7KkFbPAx04hjdYEMZF/YbnHzPUSIK4kM6OE2GKGBTpV9A== + dependencies: + "@grpc/grpc-js" "^1.14.3" + "@opentelemetry/core" "2.7.1" + "@opentelemetry/otlp-exporter-base" "0.218.0" + "@opentelemetry/otlp-transformer" "0.218.0" + +"@opentelemetry/otlp-transformer@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz#6784d0ddd13803c63a1b24072606c02fc21b9071" + integrity sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ== + dependencies: + "@opentelemetry/api-logs" "0.218.0" + "@opentelemetry/core" "2.7.1" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/sdk-logs" "0.218.0" + "@opentelemetry/sdk-metrics" "2.7.1" + "@opentelemetry/sdk-trace-base" "2.7.1" + +"@opentelemetry/propagator-b3@2.7.1": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-b3/-/propagator-b3-2.7.1.tgz#107fe3e16d0728c489edbad221c402ee197514a4" + integrity sha512-RJid6E2CKyeGfKBzXKF21ejabGMHypFkPAh3qZ+NvI+SGjuIye79t3PmiqcDgtRzdKH6ynXzbfslQ8DfpRUg2A== + dependencies: + "@opentelemetry/core" "2.7.1" + +"@opentelemetry/propagator-jaeger@2.7.1": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.7.1.tgz#e8ebf3f6c0e9aa525cf041893425889cf3e69125" + integrity sha512-KMjVBHzP4N60bOzxja76M1F1hZZ43lGPga5ix+mkv9+kk1nx9SbkxSvJsMbuVUxdPQmsPTqGShmhN8ulrMOg6Q== + dependencies: + "@opentelemetry/core" "2.7.1" + +"@opentelemetry/redis-common@^0.38.3": + version "0.38.3" + resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.3.tgz#31a0464a48a991c29408614e3725d94db7c11aee" + integrity sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw== + +"@opentelemetry/resources@2.7.1", "@opentelemetry/resources@^2.7.0": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.7.1.tgz#3b2a9179f6119bb1f2cddefe41ba9b2855504a5d" + integrity sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ== + dependencies: + "@opentelemetry/core" "2.7.1" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-logs@0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz#78886fe300b82802cee9963208b2af326b928af5" + integrity sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag== + dependencies: + "@opentelemetry/api-logs" "0.218.0" + "@opentelemetry/core" "2.7.1" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-metrics@2.7.1": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz#b713f69dd67933ecc9c61357f1d452cdc9f4e281" + integrity sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ== + dependencies: + "@opentelemetry/core" "2.7.1" + "@opentelemetry/resources" "2.7.1" + +"@opentelemetry/sdk-node@~0.218.0": + version "0.218.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-node/-/sdk-node-0.218.0.tgz#cf8bdc0c993387189c7c474612e0f801505feb97" + integrity sha512-tPMjHrLV5gsfNdYqoRHjeGbCAZBXXD9c1Qo/2ut7VwnUABDNh76xNxrT0SEhkIIJuCN45bbN1vZnYL1gY0IkOg== + dependencies: + "@opentelemetry/api-logs" "0.218.0" + "@opentelemetry/configuration" "0.218.0" + "@opentelemetry/context-async-hooks" "2.7.1" + "@opentelemetry/core" "2.7.1" + "@opentelemetry/exporter-logs-otlp-grpc" "0.218.0" + "@opentelemetry/exporter-logs-otlp-http" "0.218.0" + "@opentelemetry/exporter-logs-otlp-proto" "0.218.0" + "@opentelemetry/exporter-metrics-otlp-grpc" "0.218.0" + "@opentelemetry/exporter-metrics-otlp-http" "0.218.0" + "@opentelemetry/exporter-metrics-otlp-proto" "0.218.0" + "@opentelemetry/exporter-prometheus" "0.218.0" + "@opentelemetry/exporter-trace-otlp-grpc" "0.218.0" + "@opentelemetry/exporter-trace-otlp-http" "0.218.0" + "@opentelemetry/exporter-trace-otlp-proto" "0.218.0" + "@opentelemetry/exporter-zipkin" "2.7.1" + "@opentelemetry/instrumentation" "0.218.0" + "@opentelemetry/otlp-exporter-base" "0.218.0" + "@opentelemetry/propagator-b3" "2.7.1" + "@opentelemetry/propagator-jaeger" "2.7.1" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/sdk-logs" "0.218.0" + "@opentelemetry/sdk-metrics" "2.7.1" + "@opentelemetry/sdk-trace-base" "2.7.1" + "@opentelemetry/sdk-trace-node" "2.7.1" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-trace-base@2.7.1", "@opentelemetry/sdk-trace-base@^2.7.0": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz#9160c3af9ef2219c26563abd136e22fb7d19b34f" + integrity sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw== + dependencies: + "@opentelemetry/core" "2.7.1" + "@opentelemetry/resources" "2.7.1" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-trace-node@2.7.1": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz#54dedb8e77fa51a6d02fc2192097739266c82168" + integrity sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg== + dependencies: + "@opentelemetry/context-async-hooks" "2.7.1" + "@opentelemetry/core" "2.7.1" + "@opentelemetry/sdk-trace-base" "2.7.1" + +"@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.33.0": + version "1.40.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz#10b2944ca559386590683392022a897eefd011d3" + integrity sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw== "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" + integrity sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0", "@protobufjs/inquire@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.1.tgz#6cb936f4ac50965230af1e9d0bbfd57ea3675aa4" + integrity sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.1.tgz#eaee5900122c110a3dbcb728c0597014a2621774" + integrity sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg== + "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" @@ -5808,6 +6220,13 @@ dependencies: undici-types "~6.20.0" +"@types/node@>=13.7.0": + version "25.6.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca" + integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ== + dependencies: + undici-types "~7.19.0" + "@types/triple-beam@^1.3.2": version "1.3.5" resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" @@ -5926,6 +6345,11 @@ accesscontrol@^2.2.1: dependencies: notation "^1.3.6" +acorn-import-attributes@^1.9.5: + version "1.9.5" + resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" + integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -5936,6 +6360,11 @@ acorn@^8.14.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== +acorn@^8.15.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + agent-base@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" @@ -6706,6 +7135,11 @@ chownr@^3.0.0: resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4" integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g== +cjs-module-lexer@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz#b3ca5101843389259ade7d88c77bd06ce55849ca" + integrity sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ== + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -8010,6 +8444,11 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +forwarded-parse@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/forwarded-parse/-/forwarded-parse-2.1.2.tgz#08511eddaaa2ddfd56ba11138eee7df117a09325" + integrity sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw== + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -8534,6 +8973,16 @@ import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" +import-in-the-middle@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz#8a0a1230c9b865c0e12698171646ae1e3fff691d" + integrity sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA== + dependencies: + acorn "^8.15.0" + acorn-import-attributes "^1.9.5" + cjs-module-lexer "^2.2.0" + module-details-from-path "^1.0.4" + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -9503,6 +9952,11 @@ lodash-compat@^3.10.2: resolved "https://registry.yarnpkg.com/lodash-compat/-/lodash-compat-3.10.2.tgz#c6940128a9d30f8e902cd2cf99fd0cba4ecfc183" integrity sha512-k8SE/OwvWfYZqx3MA/Ry1SHBDWre8Z8tCs0Ba0bF5OqVNvymxgFZ/4VDtbTxzTvcoG11JpTMFsaeZp/yGYvFnA== +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + lodash.defaults@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" @@ -9620,6 +10074,11 @@ long-timeout@0.1.1: resolved "https://registry.yarnpkg.com/long-timeout/-/long-timeout-0.1.1.tgz#9721d788b47e0bcb5a24c2e2bee1a0da55dab514" integrity sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w== +long@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + looper@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/looper/-/looper-2.0.0.tgz#66cd0c774af3d4fedac53794f742db56da8f09ec" @@ -9963,6 +10422,11 @@ mocha@^11.7.5: yargs-parser "^21.1.1" yargs-unparser "^2.0.0" +module-details-from-path@^1.0.3, module-details-from-path@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" + integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== + moment@^2.30.1: version "2.30.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" @@ -10656,6 +11120,24 @@ promise-retry@^2.0.1: err-code "^2.0.2" retry "^0.12.0" +protobufjs@^7.5.3: + version "7.5.6" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.6.tgz#11af832ebc4b4326f658a5b1308e6141eb57edfd" + integrity sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.5" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.1" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.1" + "@types/node" ">=13.7.0" + long "^5.0.0" + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -10922,6 +11404,14 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== +require-in-the-middle@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz#dbde2587f669398626d56b20c868ab87bf01cce4" + integrity sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ== + dependencies: + debug "^4.3.5" + module-details-from-path "^1.0.3" + require-main-filename@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" @@ -11985,6 +12475,11 @@ undici-types@~6.20.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici-types@~7.19.0: + version "7.19.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.19.2.tgz#1b67fc26d0f157a0cba3a58a5b5c1e2276b8ba2a" + integrity sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg== + unique-filename@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-4.0.0.tgz#a06534d370e7c977a939cd1d11f7f0ab8f1fed13" @@ -12448,6 +12943,11 @@ yallist@^5.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533" integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== +yaml@^2.0.0: + version "2.8.4" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.4.tgz#4b5f411dd25f9544914d8673d4da7f29248e5e2e" + integrity sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog== + yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" From 84d79b19ee8d76e10df1b02d6dbf60fb89013583 Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Tue, 5 May 2026 18:57:04 +0200 Subject: [PATCH 2/4] feat: flush OTEL on shutdown Wire shutdownOtel() into the server's cleanUp() chain between closing HTTP servers and process.exit(0). Without this, async sdk.shutdown() fired by signal handlers can race the exit and lose buffered spans for whatever was still in flight at SIGTERM time. Inbound traceparent extraction is intentionally NOT done here: @opentelemetry/instrumentation-http already calls propagation.extract on every incoming request, creates a server span as a child of the remote parent, and sets that server span as the active context. A manual extract on top of that would replace the active context with the (non-recording) remote parent and demote downstream api spans to siblings - rather than children - of the HTTP server span, breaking the trace hierarchy in exactly the distributed-tracing scenarios the manual block was meant to support. Issue: CLDSRV-884 --- lib/server.js | 96 ++++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 47 deletions(-) diff --git a/lib/server.js b/lib/server.js index a5bb364b61..1946633950 100644 --- a/lib/server.js +++ b/lib/server.js @@ -6,6 +6,7 @@ const arsenal = require('arsenal'); const { setServerHeader } = arsenal.s3routes.routesUtils; const { RedisClient, StatsClient } = arsenal.metrics; const monitoringClient = require('./utilities/monitoringHandler'); +const tracing = require('./tracing'); const logger = require('./utilities/logger'); const { internalHandlers } = require('./utilities/internalHandlers'); @@ -15,15 +16,11 @@ const { blacklistedPrefixes } = require('../constants'); const api = require('./api/api'); const dataWrapper = require('./data/wrapper'); const kms = require('./kms/wrapper'); -const locationStorageCheck = - require('./api/apiUtils/object/locationStorageCheck'); +const locationStorageCheck = require('./api/apiUtils/object/locationStorageCheck'); const vault = require('./auth/vault'); const metadata = require('./metadata/wrapper'); const { initManagement } = require('./management'); -const { - initManagementClient, - isManagementAgentUsed, -} = require('./management/agentClient'); +const { initManagementClient, isManagementAgentUsed } = require('./management/agentClient'); const { startCleanupJob } = require('./api/apiUtils/rateLimit/cleanup'); const { startRefillJob, stopRefillJob } = require('./api/apiUtils/rateLimit/refillJob'); @@ -46,8 +43,7 @@ updateAllEndpoints(); _config.on('location-constraints-update', () => { if (implName === 'multipleBackends') { const clients = parseLC(_config, vault); - client = new MultipleBackendGateway( - clients, metadata, locationStorageCheck); + client = new MultipleBackendGateway(clients, metadata, locationStorageCheck); } }); @@ -59,8 +55,7 @@ if (_config.localCache) { // stats client const STATS_INTERVAL = 5; // 5 seconds const STATS_EXPIRY = 30; // 30 seconds -const statsClient = new StatsClient(localCacheClient, STATS_INTERVAL, - STATS_EXPIRY); +const statsClient = new StatsClient(localCacheClient, STATS_INTERVAL, STATS_EXPIRY); const enableRemoteManagement = true; class S3Server { @@ -84,7 +79,7 @@ class S3Server { process.on('SIGHUP', this.cleanUp.bind(this)); process.on('SIGQUIT', this.cleanUp.bind(this)); process.on('SIGTERM', this.cleanUp.bind(this)); - process.on('SIGPIPE', () => { }); + process.on('SIGPIPE', () => {}); // This will pick up exceptions up the stack process.on('uncaughtException', err => { // If just send the error object results in empty @@ -130,9 +125,10 @@ class S3Server { const requestStartTime = process.hrtime.bigint(); // Skip server access logs for heartbeat. - const isLoggingEnabled = _config.serverAccessLogs - && (_config.serverAccessLogs.mode === serverAccessLogsModes.LOG_ONLY - || _config.serverAccessLogs.mode === serverAccessLogsModes.ENABLED); + const isLoggingEnabled = + _config.serverAccessLogs && + (_config.serverAccessLogs.mode === serverAccessLogsModes.LOG_ONLY || + _config.serverAccessLogs.mode === serverAccessLogsModes.ENABLED); const isInternalRoute = req.url.startsWith('/_'); const isBackbeatRoute = req.url.startsWith('/_/backbeat/'); if (isLoggingEnabled && (!isInternalRoute || isBackbeatRoute)) { @@ -176,9 +172,7 @@ class S3Server { labels.action = req.apiMethod; } monitoringClient.httpRequestsTotal.labels(labels).inc(); - monitoringClient.httpRequestDurationSeconds - .labels(labels) - .observe(responseTimeInNs / 1e9); + monitoringClient.httpRequestDurationSeconds.labels(labels).observe(responseTimeInNs / 1e9); monitoringClient.httpActiveRequests.dec(); }; res.on('close', monitorEndOfRequest); @@ -206,6 +200,7 @@ class S3Server { vault, }, }; + arsenal.s3routes.routes(req, res, params, logger, this.config); } @@ -231,14 +226,13 @@ class S3Server { }; let reqUids = req.headers['x-scal-request-uids']; - if (reqUids !== undefined && !/*isValidReqUids*/(reqUids.length < 128)) { + if (reqUids !== undefined && !(/*isValidReqUids*/ (reqUids.length < 128))) { // simply ignore invalid id (any user can provide an // invalid request ID through a crafted header) reqUids = undefined; } - const log = (reqUids !== undefined ? - logger.newRequestLoggerFromSerializedUids(reqUids) : - logger.newRequestLogger()); + const log = + reqUids !== undefined ? logger.newRequestLoggerFromSerializedUids(reqUids) : logger.newRequestLogger(); log.end().addDefaultFields(clientInfo); log.debug('received admin request', clientInfo); @@ -292,8 +286,7 @@ class S3Server { server.requestTimeout = 0; // disabling request timeout server.on('connection', socket => { - socket.on('error', err => logger.info('request rejected', - { error: err })); + socket.on('error', err => logger.info('request rejected', { error: err })); }); // https://nodejs.org/dist/latest-v6.x/ @@ -309,8 +302,11 @@ class S3Server { }; const { address } = addr; logger.info('server started', { - address, port, - pid: process.pid, serverIP: address, serverPort: port + address, + port, + pid: process.pid, + serverIP: address, + serverPort: port, }); }); @@ -323,32 +319,41 @@ class S3Server { this.servers.push(server); } - /* - * This exits the running process properly. - */ - cleanUp() { + async cleanUp() { logger.info('server shutting down'); - // Stop token refill job if running if (this.config.rateLimiting?.enabled) { stopRefillJob(logger); } - Promise.all(this.servers.map(server => - new Promise(resolve => server.close(resolve)) - )).then(() => process.exit(0)); + try { + await Promise.all(this.servers.map(server => new Promise(resolve => server.close(resolve)))); + await tracing.close(); + } finally { + process.exit(0); + } } - caughtExceptionShutdown() { + async caughtExceptionShutdown() { if (!this.cluster) { - process.exit(1); + try { + await tracing.close(); + } finally { + process.exit(1); + } + return; } logger.error('shutdown of worker due to exception', { workerId: this.worker ? this.worker.id : undefined, workerPid: this.worker ? this.worker.process.pid : undefined, }); - // Will close all servers, cause disconnect event on primary and kill - // worker process with 'SIGTERM'. + // worker.kill() is graceful (closes servers, disconnects IPC) but + // does not fire our SIGTERM handler, so the BatchSpanProcessor + // would lose buffered spans without an explicit flush here. if (this.worker) { - this.worker.kill(); + try { + await tracing.close(); + } finally { + this.worker.kill(); + } } } @@ -363,10 +368,7 @@ class S3Server { } initiateStartup(log) { - series([ - next => metadata.setup(next), - next => clientCheck(true, log, next), - ], (err, results) => { + series([next => metadata.setup(next), next => clientCheck(true, log, next)], (err, results) => { if (err) { log.warn('initial health check failed, delaying startup', { error: err, @@ -417,8 +419,10 @@ class S3Server { try { logger.info('ServerAccessLogger config', { config: _config.serverAccessLogs }); - if (_config.serverAccessLogs.mode === serverAccessLogsModes.LOG_ONLY - || _config.serverAccessLogs.mode === serverAccessLogsModes.ENABLED) { + if ( + _config.serverAccessLogs.mode === serverAccessLogsModes.LOG_ONLY || + _config.serverAccessLogs.mode === serverAccessLogsModes.ENABLED + ) { var serverAccessLogger = new ServerAccessLogger( _config.serverAccessLogs.outputFile, _config.serverAccessLogs.highWaterMarkBytes, @@ -434,7 +438,6 @@ class S3Server { logger.error('ServerAccessLogger creation error', error); } - this.started = true; }); } @@ -490,8 +493,7 @@ function main() { }); const metricServer = new S3Server(_config); - metricServer.startServer(_config.metricsListenOn, - _config.metricsPort, metricServer.routeAdminRequest); + metricServer.startServer(_config.metricsListenOn, _config.metricsPort, metricServer.routeAdminRequest); } if (_config.isCluster && cluster.isWorker) { const server = new S3Server(_config, cluster.worker); From 035b5da33547be945d70f3f97dfb291ca0e8186f Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Tue, 5 May 2026 18:58:43 +0200 Subject: [PATCH 3/4] feat: instrument all S3 API handlers with OTEL spans Add lib/instrumentation/simple.js exporting instrumentApiMethod, a wrapper that surrounds an S3 handler invocation with an OTEL span named api.. The span owns the entire handler execution (auth, body parsing, metadata I/O, data path, finalizers) and becomes the parent for any auto-instrumentation spans (HTTP, MongoDB, ioredis) that fire underneath. Span name is the handler name verbatim - objectGetACL stays distinct from objectGet, objectPutTagging stays distinct from objectPut. ~70 handlers means ~70 distinct span names total, well within trace backend limits, and operators can tell variants apart without reading attributes. api.js applies the wrapper to every function-valued key in the api object except for the dispatcher (callApiMethod) and pure helpers (checkAuthResults, handleAuthorizationResults). New handlers added to the literal are automatically instrumented - no per-handler boilerplate to remember. The wrapper handles callback / promise / sync return paths, sets SpanStatusCode.OK or ERROR + recordException as appropriate, and sets cloudserver.error_code on the error path. When ENABLE_OTEL is unset @opentelemetry/api is not loaded and the wrapper returns the original function unchanged. Issue: CLDSRV-884 --- lib/api/api.js | 325 ++++++++++--------- lib/instrumentation/simple.js | 92 ++++++ tests/unit/lib/instrumentationSimple.spec.js | 192 +++++++++++ 3 files changed, 457 insertions(+), 152 deletions(-) create mode 100644 lib/instrumentation/simple.js create mode 100644 tests/unit/lib/instrumentationSimple.spec.js diff --git a/lib/api/api.js b/lib/api/api.js index 0f6d39f5ef..d1408f039a 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -69,8 +69,7 @@ const objectPutPart = require('./objectPutPart'); const objectPutCopyPart = require('./objectPutCopyPart'); const objectPutRetention = require('./objectPutRetention'); const objectRestore = require('./objectRestore'); -const prepareRequestContexts - = require('./apiUtils/authorization/prepareRequestContexts'); +const prepareRequestContexts = require('./apiUtils/authorization/prepareRequestContexts'); const serviceGet = require('./serviceGet'); const vault = require('../auth/vault'); const website = require('./website'); @@ -80,6 +79,7 @@ const parseCopySource = require('./apiUtils/object/parseCopySource'); const { tagConditionKeyAuth } = require('./apiUtils/authorization/tagConditionKeys'); const { isRequesterASessionUser } = require('./apiUtils/authorization/permissionChecks'); const checkHttpHeadersSize = require('./apiUtils/object/checkHttpHeadersSize'); +const { instrumentApiMethod } = require('../instrumentation/simple'); const constants = require('../../constants'); const { config } = require('../Config.js'); const metadata = require('../metadata/wrapper'); @@ -102,8 +102,7 @@ auth.setHandler(vault); // scan the args instead of relying on a fixed position. function hasCorsHeaders(callbackArgs) { for (const arg of callbackArgs) { - if (!arg || typeof arg !== 'object' || Array.isArray(arg) - || Buffer.isBuffer(arg)) { + if (!arg || typeof arg !== 'object' || Array.isArray(arg) || Buffer.isBuffer(arg)) { continue; } if ('access-control-allow-origin' in arg) { @@ -137,22 +136,21 @@ function wrapCallbackWithErrorCorsHeaders(callback, request, response, log) { if (!bucket || response.headersSent) { return; } - const headers = collectCorsHeaders( - request.headers.origin, request.method, bucket); + const headers = collectCorsHeaders(request.headers.origin, request.method, bucket); Object.keys(headers).forEach(key => { try { response.setHeader(key, headers[key]); } catch (e) { log.debug('could not set CORS header on error', { - header: key, error: e.message, + header: key, + error: e.message, }); } }); } return (err, ...callbackArgs) => { - if (!err || !request.headers || !request.headers.origin - || !request.bucketName) { + if (!err || !request.headers || !request.headers.origin || !request.bucketName) { return callback(err, ...callbackArgs); } // Fast path: most post-auth failures come back with corsHeaders @@ -194,8 +192,7 @@ function checkAuthResults(authResults, apiMethod, log) { isImplicitDeny[authResults[0].action] = authResults[0].isImplicit; // second item checks s3:GetObject(Version)Tagging action if (!authResults[1].isAllowed) { - log.trace('get tagging authorization denial ' + - 'from Vault'); + log.trace('get tagging authorization denial ' + 'from Vault'); returnTagCount = false; } } else { @@ -256,14 +253,15 @@ function callApiHandler(apiMethod, apiHandler, request, response, log, callback) } // no need to check auth on website or cors preflight requests - if (apiMethod === 'websiteGet' || apiMethod === 'websiteHead' || - apiMethod === 'corsPreflight') { + if (apiMethod === 'websiteGet' || apiMethod === 'websiteHead' || apiMethod === 'corsPreflight') { request.actionImplicitDenies = false; return apiHandler(request, log, callback); } - const { sourceBucket, sourceObject, sourceVersionId, parsingError } = - parseCopySource(apiMethod, request.headers['x-amz-copy-source']); + const { sourceBucket, sourceObject, sourceVersionId, parsingError } = parseCopySource( + apiMethod, + request.headers['x-amz-copy-source'], + ); if (parsingError) { log.debug('error parsing copy source', { error: parsingError, @@ -279,172 +277,185 @@ function callApiHandler(apiMethod, apiHandler, request, response, log, callback) return process.nextTick(callback, httpHeadersSizeError); } - const requestContexts = prepareRequestContexts(apiMethod, request, - sourceBucket, sourceObject, sourceVersionId); + const requestContexts = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); // Extract all the _apiMethods and store them in an array const apiMethods = requestContexts ? requestContexts.map(context => context._apiMethod) : []; // Attach the names to the current request request.apiMethods = apiMethods; - return async.waterfall([ - next => auth.server.doAuth( - request, log, (err, userInfo, authorizationResults, streamingV4Params, infos) => { - if (request.serverAccessLog) { - request.serverAccessLog.authInfo = userInfo; + return async.waterfall( + [ + next => + auth.server.doAuth( + request, + log, + (err, userInfo, authorizationResults, streamingV4Params, infos) => { + if (request.serverAccessLog) { + request.serverAccessLog.authInfo = userInfo; + } + if (err) { + // VaultClient returns standard errors, but the route requires + // Arsenal errors + const arsenalError = err.metadata ? err : errors[err.code] || errors.InternalError; + log.trace('authentication error', { error: err }); + return next(arsenalError); + } + return next(null, userInfo, authorizationResults, streamingV4Params, infos); + }, + 's3', + requestContexts, + ), + (userInfo, authorizationResults, streamingV4Params, infos, next) => { + const authNames = { accountName: userInfo.getAccountDisplayName() }; + if (userInfo.isRequesterAnIAMUser()) { + authNames.userName = userInfo.getIAMdisplayName(); } - if (err) { - // VaultClient returns standard errors, but the route requires - // Arsenal errors - const arsenalError = err.metadata ? err : errors[err.code] || errors.InternalError; - log.trace('authentication error', { error: err }); - return next(arsenalError); + if (isRequesterASessionUser(userInfo)) { + authNames.sessionName = userInfo.getShortid().split(':')[1]; } - return next(null, userInfo, authorizationResults, streamingV4Params, infos); - }, 's3', requestContexts), - (userInfo, authorizationResults, streamingV4Params, infos, next) => { - const authNames = { accountName: userInfo.getAccountDisplayName() }; - if (userInfo.isRequesterAnIAMUser()) { - authNames.userName = userInfo.getIAMdisplayName(); - } - if (isRequesterASessionUser(userInfo)) { - authNames.sessionName = userInfo.getShortid().split(':')[1]; - } - log.addDefaultFields(authNames); - if (request.serverAccessLog) { - request.serverAccessLog.analyticsAccountName = authNames.accountName; - request.serverAccessLog.analyticsUserName = authNames.userName; - } - if (apiMethod === 'objectPut' || apiMethod === 'objectPutPart') { - const setStartTurnAroundTime = () => { - if (request.serverAccessLog) { - request.serverAccessLog.startTurnAroundTime = process.hrtime.bigint(); - } - }; - // For 0-byte uploads, downstream handlers do not consume - // the request stream, so 'end' never fires. Set - // startTurnAroundTime synchronously in that case. - if (request.headers['content-length'] === '0') { - setStartTurnAroundTime(); - } else { - request.on('end', setStartTurnAroundTime); + log.addDefaultFields(authNames); + if (request.serverAccessLog) { + request.serverAccessLog.analyticsAccountName = authNames.accountName; + request.serverAccessLog.analyticsUserName = authNames.userName; } - return next(null, userInfo, authorizationResults, streamingV4Params, infos); - } - // issue 100 Continue to the client - writeContinue(request, response); - - const defaultMaxBodyLength = request.method === 'POST' ? - constants.oneMegaBytes : constants.halfMegaBytes; - const MAX_BODY_LENGTH = config.apiBodySizeLimits[apiMethod] || defaultMaxBodyLength; - const post = []; - let bodyLength = 0; - request.on('data', chunk => { - bodyLength += chunk.length; - // Sanity check on post length - if (bodyLength <= MAX_BODY_LENGTH) { - post.push(chunk); + if (apiMethod === 'objectPut' || apiMethod === 'objectPutPart') { + const setStartTurnAroundTime = () => { + if (request.serverAccessLog) { + request.serverAccessLog.startTurnAroundTime = process.hrtime.bigint(); + } + }; + // For 0-byte uploads, downstream handlers do not consume + // the request stream, so 'end' never fires. Set + // startTurnAroundTime synchronously in that case. + if (request.headers['content-length'] === '0') { + setStartTurnAroundTime(); + } else { + request.on('end', setStartTurnAroundTime); + } + return next(null, userInfo, authorizationResults, streamingV4Params, infos); } - }); + // issue 100 Continue to the client + writeContinue(request, response); + + const defaultMaxBodyLength = + request.method === 'POST' ? constants.oneMegaBytes : constants.halfMegaBytes; + const MAX_BODY_LENGTH = config.apiBodySizeLimits[apiMethod] || defaultMaxBodyLength; + const post = []; + let bodyLength = 0; + request.on('data', chunk => { + bodyLength += chunk.length; + // Sanity check on post length + if (bodyLength <= MAX_BODY_LENGTH) { + post.push(chunk); + } + }); - request.on('error', err => { - log.trace('error receiving request', { - error: err, + request.on('error', err => { + log.trace('error receiving request', { + error: err, + }); + return next(errors.InternalError); }); - return next(errors.InternalError); - }); - request.on('end', () => { - if (request.serverAccessLog) { - request.serverAccessLog.startTurnAroundTime = process.hrtime.bigint(); - } + request.on('end', () => { + if (request.serverAccessLog) { + request.serverAccessLog.startTurnAroundTime = process.hrtime.bigint(); + } - if (bodyLength > MAX_BODY_LENGTH) { - log.error('body length is too long for request type', - { bodyLength }); - return next(errors.InvalidRequest); - } + if (bodyLength > MAX_BODY_LENGTH) { + log.error('body length is too long for request type', { bodyLength }); + return next(errors.InvalidRequest); + } - const buff = Buffer.concat(post, bodyLength); + const buff = Buffer.concat(post, bodyLength); - const err = validateMethodChecksumNoChunking(request, buff, log); - if (err) { - return next(err); - } + const err = validateMethodChecksumNoChunking(request, buff, log); + if (err) { + return next(err); + } - // Convert array of post buffers into one string - request.post = buff.toString(); - return next(null, userInfo, authorizationResults, streamingV4Params, infos); - }); - return undefined; - }, - // Tag condition keys require information from CloudServer for evaluation - (userInfo, authorizationResults, streamingV4Params, infos, next) => tagConditionKeyAuth( - authorizationResults, - request, - requestContexts, - apiMethod, - log, - (err, authResultsWithTags) => { - if (err) { - log.trace('tag authentication error', { error: err }); - return next(err); - } - return next(null, userInfo, authResultsWithTags, streamingV4Params, infos); + // Convert array of post buffers into one string + request.post = buff.toString(); + return next(null, userInfo, authorizationResults, streamingV4Params, infos); + }); + return undefined; }, - ), - (userInfo, authorizationResults, streamingV4Params, infos, next) => handleAuthorizationResults( - request, authorizationResults, apiMethod, returnTagCount, log, (err, res) => { - request.accountQuotas = infos?.accountQuota; - request.accountLimits = infos?.limits; - if (err) { - return next(err); - } - returnTagCount = res.returnTagCount; - return next(null, userInfo, authorizationResults, streamingV4Params); - }), - ], (err, userInfo, authorizationResults, streamingV4Params) => { - if (err) { - return callback(err); - } - const methodCallback = (err, ...results) => async.forEachLimit(request.finalizerHooks, 5, - (hook, done) => hook(err, done), - () => callback(err, ...results)); - - if (apiMethod === 'objectPut' || apiMethod === 'objectPutPart') { - request._response = response; - return apiHandler(userInfo, request, streamingV4Params, - log, methodCallback, authorizationResults); - } - if (apiMethod === 'objectCopy' || apiMethod === 'objectPutCopyPart') { - return apiHandler(userInfo, request, sourceBucket, - sourceObject, sourceVersionId, log, methodCallback); - } - if (apiMethod === 'objectGet') { - // remove objectGetTagging/objectGetTaggingVersion from apiMethods, these were added by - // prepareRequestContexts to determine the value of returnTagCount. - request.apiMethods = request.apiMethods.filter(methodName => !methodName.includes('Tagging')); - return apiHandler(userInfo, request, returnTagCount, log, callback); - } - return apiHandler(userInfo, request, log, methodCallback); - }); + // Tag condition keys require information from CloudServer for evaluation + (userInfo, authorizationResults, streamingV4Params, infos, next) => + tagConditionKeyAuth( + authorizationResults, + request, + requestContexts, + apiMethod, + log, + (err, authResultsWithTags) => { + if (err) { + log.trace('tag authentication error', { error: err }); + return next(err); + } + return next(null, userInfo, authResultsWithTags, streamingV4Params, infos); + }, + ), + (userInfo, authorizationResults, streamingV4Params, infos, next) => + handleAuthorizationResults( + request, + authorizationResults, + apiMethod, + returnTagCount, + log, + (err, res) => { + request.accountQuotas = infos?.accountQuota; + request.accountLimits = infos?.limits; + if (err) { + return next(err); + } + returnTagCount = res.returnTagCount; + return next(null, userInfo, authorizationResults, streamingV4Params); + }, + ), + ], + (err, userInfo, authorizationResults, streamingV4Params) => { + if (err) { + return callback(err); + } + const methodCallback = (err, ...results) => + async.forEachLimit( + request.finalizerHooks, + 5, + (hook, done) => hook(err, done), + () => callback(err, ...results), + ); + + if (apiMethod === 'objectPut' || apiMethod === 'objectPutPart') { + request._response = response; + return apiHandler(userInfo, request, streamingV4Params, log, methodCallback, authorizationResults); + } + if (apiMethod === 'objectCopy' || apiMethod === 'objectPutCopyPart') { + return apiHandler(userInfo, request, sourceBucket, sourceObject, sourceVersionId, log, methodCallback); + } + if (apiMethod === 'objectGet') { + // remove objectGetTagging/objectGetTaggingVersion from apiMethods, these were added by + // prepareRequestContexts to determine the value of returnTagCount. + request.apiMethods = request.apiMethods.filter(methodName => !methodName.includes('Tagging')); + return apiHandler(userInfo, request, returnTagCount, log, callback); + } + return apiHandler(userInfo, request, log, methodCallback); + }, + ); } const api = { callApiMethod(apiMethod, request, response, log, callback) { // Attach the apiMethod method to the request, so it can used by monitoring in the server request.apiMethod = apiMethod; - callback = wrapCallbackWithErrorCorsHeaders( - callback, request, response, log); + callback = wrapCallbackWithErrorCorsHeaders(callback, request, response, log); // Array of end of API callbacks, used to perform some logic // at the end of an API. request.finalizerHooks = []; const actionLog = monitoringMap[apiMethod]; - if (!actionLog && - apiMethod !== 'websiteGet' && - apiMethod !== 'websiteHead' && - apiMethod !== 'corsPreflight') { + if (!actionLog && apiMethod !== 'websiteGet' && apiMethod !== 'websiteHead' && apiMethod !== 'corsPreflight') { log.error('callApiMethod(): No actionLog for this api method', { apiMethod, }); @@ -586,4 +597,14 @@ const api = { handleAuthorizationResults, }; +// Denylist (not allowlist) so newly-added S3 handlers are auto-traced +// without a separate registration step. The three skipped keys are +// internal helpers, not S3 operations. +const NON_INSTRUMENTED_KEYS = new Set(['callApiMethod', 'checkAuthResults', 'handleAuthorizationResults']); +for (const [name, handler] of Object.entries(api)) { + if (typeof handler === 'function' && !NON_INSTRUMENTED_KEYS.has(name)) { + api[name] = instrumentApiMethod(handler, name); + } +} + module.exports = api; diff --git a/lib/instrumentation/simple.js b/lib/instrumentation/simple.js new file mode 100644 index 0000000000..55f46ba6c6 --- /dev/null +++ b/lib/instrumentation/simple.js @@ -0,0 +1,92 @@ +'use strict'; + +const tracing = require('../tracing'); + +let tracer = null; +function getTracer() { + if (tracer) { + return tracer; + } + const { trace } = require('@opentelemetry/api'); + const { version } = require('../../package.json'); + tracer = trace.getTracer('cloudserver-api', version); + return tracer; +} + +async function endSpanWhenSettled(promise, endSpan) { + try { + const value = await promise; + endSpan(); + return value; + } catch (err) { + endSpan(err); + throw err; + } +} + +function instrumentApiMethod(apiMethod, methodName) { + if (!tracing.isEnabled()) { + return apiMethod; + } + + const api = require('@opentelemetry/api'); + const spanName = `api.${methodName}`; + + return function instrumented(...args) { + const callbackIndex = args.findLastIndex(a => typeof a === 'function'); + const span = getTracer().startSpan(spanName, { kind: api.SpanKind.INTERNAL }); + + // End-once guard. Multiple termination paths can race: the + // wrapped callback may fire and then the handler may also throw + // synchronously, or a callback-and-Promise hybrid handler may + // resolve after firing the callback. + let spanEnded = false; + const endSpan = err => { + if (spanEnded) { + return; + } + spanEnded = true; + if (err) { + span.recordException(err); + span.setStatus({ code: api.SpanStatusCode.ERROR }); + if (err.code) { + span.setAttribute('cloudserver.error_code', err.code); + } + } else { + span.setStatus({ code: api.SpanStatusCode.OK }); + } + span.end(); + }; + + const wrappedArgs = [...args]; + if (callbackIndex !== -1) { + const originalCallback = args[callbackIndex]; + wrappedArgs[callbackIndex] = function wrappedCallback(err, ...results) { + endSpan(err); + return originalCallback.call(this, err, ...results); + }; + } + + const ctx = api.trace.setSpan(api.context.active(), span); + try { + const result = api.context.with(ctx, () => apiMethod.apply(this, wrappedArgs)); + if (callbackIndex === -1) { + if (result && typeof result.then === 'function') { + return endSpanWhenSettled(result, endSpan); + } + endSpan(); + } + // Callback-style handler: the wrapped callback drives the + // span lifecycle. If the handler also returns a thenable + // (hybrid migration shape), pass it through untouched — + // attaching a second .then() chain would surface as an + // unhandled rejection in callback-only callers. + return result; + } catch (error) { + endSpan(error); + throw error; + } + }; +} + +module.exports = { instrumentApiMethod }; diff --git a/tests/unit/lib/instrumentationSimple.spec.js b/tests/unit/lib/instrumentationSimple.spec.js new file mode 100644 index 0000000000..9c023918a5 --- /dev/null +++ b/tests/unit/lib/instrumentationSimple.spec.js @@ -0,0 +1,192 @@ +'use strict'; + +// Force the module under test onto its OTEL-on path. Must be set before +// any require pulls in lib/instrumentation/simple. +process.env.ENABLE_OTEL = 'true'; + +const assert = require('assert'); +const { trace, SpanStatusCode } = require('@opentelemetry/api'); +const { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, + AlwaysOnSampler, +} = require('@opentelemetry/sdk-trace-base'); + +const exporter = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ + sampler: new AlwaysOnSampler(), + spanProcessors: [new SimpleSpanProcessor(exporter)], +}); +trace.setGlobalTracerProvider(provider); + +const { instrumentApiMethod } = require('../../../lib/instrumentation/simple'); + +describe('instrumentApiMethod', () => { + describe('OTEL on', () => { + afterEach(() => exporter.reset()); + + it('wraps a callback handler and ends span on success', done => { + const handler = (a, b, cb) => cb(null, 'ok'); + const wrapped = instrumentApiMethod(handler, 'objectGet'); + + wrapped('foo', 'bar', (err, value) => { + assert.strictEqual(err, null); + assert.strictEqual(value, 'ok'); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].name, 'api.objectGet'); + assert.strictEqual(spans[0].status.code, SpanStatusCode.OK); + done(); + }); + }); + + it("ends span with ERROR when handler's callback fires with err", done => { + const handler = (a, cb) => cb(Object.assign(new Error('nope'), { code: 'NoSuchBucket' })); + const wrapped = instrumentApiMethod(handler, 'bucketHead'); + + wrapped('foo', err => { + assert.strictEqual(err.message, 'nope'); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].status.code, SpanStatusCode.ERROR); + assert.strictEqual(spans[0].attributes['cloudserver.error_code'], 'NoSuchBucket'); + done(); + }); + }); + + it('ends span exactly once when callback fires then handler throws', () => { + // The first endSpan call (from the callback) wins; the + // post-callback throw is a programming bug, not an outcome + // of the API call — so status stays OK and we don't double- + // end (which would warn and corrupt span state). + const handler = cb => { + cb(null, 'first'); + throw new Error('after-callback-boom'); + }; + const wrapped = instrumentApiMethod(handler, 'objectPut'); + + let cbErr = null; + let cbValue = null; + const cb = (err, val) => { + cbErr = err; + cbValue = val; + }; + + assert.throws(() => wrapped(cb), /after-callback-boom/); + + assert.strictEqual(cbErr, null); + assert.strictEqual(cbValue, 'first'); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1, 'span ended exactly once'); + assert.strictEqual(spans[0].status.code, SpanStatusCode.OK); + }); + + it('synchronous throw before callback fires ends span and re-throws', () => { + const handler = () => { + throw new Error('sync-boom'); + }; + const wrapped = instrumentApiMethod(handler, 'objectDelete'); + + assert.throws(() => wrapped(() => {}), /sync-boom/); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].status.code, SpanStatusCode.ERROR); + }); + + it('wraps an async handler and ends span on resolution', async () => { + const handler = async a => `async-${a}`; + const wrapped = instrumentApiMethod(handler, 'objectGetAsync'); + + const value = await wrapped('x'); + assert.strictEqual(value, 'async-x'); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].name, 'api.objectGetAsync'); + assert.strictEqual(spans[0].status.code, SpanStatusCode.OK); + }); + + it('wraps an async handler and ends span with ERROR on rejection', async () => { + const handler = async () => { + const err = new Error('async-nope'); + err.code = 'NoSuchKey'; + throw err; + }; + const wrapped = instrumentApiMethod(handler, 'objectGetAsync'); + + await assert.rejects(wrapped(), /async-nope/); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].status.code, SpanStatusCode.ERROR); + assert.strictEqual(spans[0].attributes['cloudserver.error_code'], 'NoSuchKey'); + }); + + it('callback drives lifecycle even when handler also returns a Promise', done => { + // Hybrid shape (migration artifact): handler fires cb AND + // returns a Promise. The wrapper must NOT chain its own + // .then() onto that Promise — doing so would surface as an + // unhandled rejection in callback-only callers that discard + // the return value. + const handler = (a, cb) => { + cb(null, `cb-${a}`); + return Promise.resolve(`promise-${a}`); + }; + const wrapped = instrumentApiMethod(handler, 'hybridGet'); + + let cbErr; + let cbValue; + const returned = wrapped('x', (err, value) => { + cbErr = err; + cbValue = value; + }); + + assert.strictEqual(cbErr, null); + assert.strictEqual(cbValue, 'cb-x'); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].status.code, SpanStatusCode.OK); + + // Caller still receives the handler's original Promise + // (not a chained wrapper) so they can choose to await it. + assert.ok(returned && typeof returned.then === 'function'); + returned.then(v => { + assert.strictEqual(v, 'promise-x'); + done(); + }); + }); + + it('ends span on sync return when handler has no callback arg', () => { + const handler = (a, b) => `${a}-${b}`; + const wrapped = instrumentApiMethod(handler, 'objectRestore'); + + assert.strictEqual(wrapped('x', 'y'), 'x-y'); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].status.code, SpanStatusCode.OK); + }); + }); + + describe('OTEL off', () => { + // tracing.isEnabled() is checked at each instrumentApiMethod call, + // so flipping ENABLE_OTEL between calls is enough — no require.cache + // dance needed. + it('returns the original function unchanged', () => { + const saved = process.env.ENABLE_OTEL; + process.env.ENABLE_OTEL = 'false'; + try { + const handler = () => 'identity'; + assert.strictEqual(instrumentApiMethod(handler, 'foo'), handler); + } finally { + process.env.ENABLE_OTEL = saved; + } + }); + }); +}); From b87f2191155cf899e6d199cd12f5c12f363a8471 Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Tue, 5 May 2026 18:59:41 +0200 Subject: [PATCH 4/4] chore: bump arsenal to ARSN-572 PoC branch for e2e trace context testing Temporarily point the arsenal dep at scality/Arsenal#improvement/ARSN-572/trace-context so we can validate end-to-end trace context propagation from cloudserver HTTP spans through to the MongoDB oplog on a test cluster. ARSN-572 adds traceContext plumbing on metadata writes; cloudserver needs no code change thanks to OTEL async context hooks. Yarn resolves the branch ref and pins the resolved commit hash in yarn.lock so installs are reproducible. Revert to a clean #8.x release tag once ARSN-572 ships. Issue: CLDSRV-884 --- package.json | 2 +- yarn.lock | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 86e6f376e6..f909011df6 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@opentelemetry/sdk-node": "~0.218.0", "@opentelemetry/sdk-trace-base": "^2.7.0", "@smithy/node-http-handler": "^3.0.0", - "arsenal": "git+https://github.com/scality/Arsenal#8.4.1", + "arsenal": "git+https://github.com/scality/Arsenal#improvement/ARSN-572/trace-context", "async": "2.6.4", "bucketclient": "scality/bucketclient#8.2.7", "bufferutil": "^4.0.8", diff --git a/yarn.lock b/yarn.lock index 0f203df099..04641d2313 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6639,9 +6639,9 @@ arraybuffer.prototype.slice@^1.0.4: optionalDependencies: ioctl "^2.0.2" -"arsenal@git+https://github.com/scality/Arsenal#8.4.1": - version "8.4.1" - resolved "git+https://github.com/scality/Arsenal#6b3b58b152ac23d29176ab1f24f49f8eda3145b2" +"arsenal@git+https://github.com/scality/Arsenal#improvement/ARSN-572/trace-context": + version "8.3.11" + resolved "git+https://github.com/scality/Arsenal#86191514c86e1dba0532c14ec43824ee912e7d08" dependencies: "@aws-sdk/client-kms" "^3.975.0" "@aws-sdk/client-s3" "^3.975.0" @@ -6650,6 +6650,7 @@ arraybuffer.prototype.slice@^1.0.4: "@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"