Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions experimental/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2
* feat(sdk-node): wire up `id_generator` from declarative config [#6782](https://github.com/open-telemetry/opentelemetry-js/pull/6782) @MikeGoldsmith
* feat(propagator-env-carrier): empty name normalization [#6827](https://github.com/open-telemetry/opentelemetry-js/pull/6827) @pellared
* feat(propagator-env-carrier): make `EnvironmentGetter` read the current `process.env` [#6853](https://github.com/open-telemetry/opentelemetry-js/pull/6853) @pellared
* feat(instrumentation): add experimental declarative config reader API: `applyDeclarativeConfig()` and overridable `readDeclarativeConfig()` on `InstrumentationBase`, plus a `declarativeConfigProperties()` typed accessor (snake_case keys, type-checked, warns on unrecognized keys) [#6864](https://github.com/open-telemetry/opentelemetry-js/pull/6864) @mwear
* feat(sdk-node): `startNodeSDK()` applies `instrumentation/development` config to instrumentations, gating registration on `enabled` and tolerating instrumentations built against a base that predates this API [#6864](https://github.com/open-telemetry/opentelemetry-js/pull/6864) @mwear
* feat(instrumentation-http): read supported config fields from declarative config [#6864](https://github.com/open-telemetry/opentelemetry-js/pull/6864) @mwear

### :bug: Bug Fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
"prepublishOnly": "npm run compile",
"compile": "tsc --build",
"clean": "tsc --build --clean",
"test:cjs": "nyc mocha test/**/*.test.ts",
"test:esm": "nyc node --experimental-loader=@opentelemetry/instrumentation/hook.mjs ../../../node_modules/mocha/bin/mocha 'test/**/*.test.mjs'",
"test:double-instr": "nyc node --experimental-loader=@opentelemetry/instrumentation/hook.mjs ../../../node_modules/mocha/bin/mocha 'test/integrations/double-instr.test.cjs'",
"test": "npm run test:cjs && npm run test:esm && npm run test:double-instr",
"test:cjs": "nyc --silent mocha test/**/*.test.ts",
"test:esm": "nyc --silent --no-clean node --experimental-loader=@opentelemetry/instrumentation/hook.mjs ../../../node_modules/mocha/bin/mocha 'test/**/*.test.mjs'",
"test:double-instr": "nyc --silent --no-clean node --experimental-loader=@opentelemetry/instrumentation/hook.mjs ../../../node_modules/mocha/bin/mocha 'test/integrations/double-instr.test.cjs'",
"test": "npm run test:cjs && npm run test:esm && npm run test:double-instr && nyc report",
Comment on lines +12 to +15

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The package.json test-script changes in instrumentation and instrumentation-http fix Codecov under-reporting. In a chain like npm run test:cjs && npm run test:esm && npm run test:double-instr, each subsequent nyc run clobbers the previous run's coverage (nyc wipes .nyc_output before each run), so only the last run's coverage gets uploaded. The fix accumulates coverage across runs and emits one merged report. This should probably be split into its own PR. I included it here to get this PR green. Here is what codecov looked like without these changes:

Image

"tdd": "npm run test -- --watch-extensions ts --watch",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
semconvStabilityFromStr,
safeExecuteInTheMiddle,
} from '@opentelemetry/instrumentation';
import type { DeclarativeConfigProperties } from '@opentelemetry/instrumentation';
import { errorMonitor } from 'events';
import {
ATTR_ERROR_TYPE,
Expand Down Expand Up @@ -101,6 +102,31 @@ export class HttpInstrumentation extends InstrumentationBase<HttpInstrumentation
this._headerCapture = this._createHeaderCapture(this._semconvStability);
}

protected override readDeclarativeConfig(
own: DeclarativeConfigProperties
): Partial<HttpInstrumentationConfig> {
return {
enabled: own.getBoolean('enabled'),
requireParentforOutgoingSpans: own.getBoolean(
'require_parent_for_outgoing_spans'
),
requireParentforIncomingSpans: own.getBoolean(
'require_parent_for_incoming_spans'
),
disableOutgoingRequestInstrumentation: own.getBoolean(
'disable_outgoing_request_instrumentation'
),
disableIncomingRequestInstrumentation: own.getBoolean(
'disable_incoming_request_instrumentation'
),
serverName: own.getString('server_name'),
redactedQueryParams: own.getStringArray('redacted_query_params'),
enableSyntheticSourceDetection: own.getBoolean(
'enable_synthetic_source_detection'
),
};
}

protected override _updateMetricInstruments() {
this._oldHttpServerDurationHistogram = this.meter.createHistogram(
'http.server.duration',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

import * as assert from 'assert';
import * as sinon from 'sinon';
import { diag, DiagLogLevel } from '@opentelemetry/api';
import { HttpInstrumentation } from '../../src/http';

describe('HttpInstrumentation declarative config', function () {
let warn: sinon.SinonStub;
const created: HttpInstrumentation[] = [];
function makeInstrumentation(
config?: ConstructorParameters<typeof HttpInstrumentation>[0]
): HttpInstrumentation {
const instrumentation = new HttpInstrumentation(config);
created.push(instrumentation);
return instrumentation;
}

beforeEach(function () {
warn = sinon.stub();
diag.setLogger(
{
verbose: () => {},
debug: () => {},
info: () => {},
warn,
error: () => {},
},
DiagLogLevel.WARN
);
});

afterEach(function () {
created.forEach(instrumentation => instrumentation.disable());
created.length = 0;
diag.disable();
sinon.restore();
});

it('maps snake_case keys to config fields', function () {
const instrumentation = makeInstrumentation();
instrumentation.applyDeclarativeConfig({
enabled: true,
require_parent_for_outgoing_spans: true,
server_name: 'my-server',
redacted_query_params: ['token', 'sig'],
enable_synthetic_source_detection: true,
});

const config = instrumentation.getConfig();
assert.strictEqual(config.requireParentforOutgoingSpans, true);
assert.strictEqual(config.serverName, 'my-server');
assert.deepStrictEqual(config.redactedQueryParams, ['token', 'sig']);
assert.strictEqual(config.enableSyntheticSourceDetection, true);
sinon.assert.notCalled(warn);
});

it('leaves unset fields at their existing value', function () {
const instrumentation = makeInstrumentation({ serverName: 'keep-me' });
instrumentation.applyDeclarativeConfig({
require_parent_for_incoming_spans: true,
});

const config = instrumentation.getConfig();
assert.strictEqual(config.requireParentforIncomingSpans, true);
assert.strictEqual(config.serverName, 'keep-me');
});

it('warns about a key it does not read', function () {
const instrumentation = makeInstrumentation();
instrumentation.applyDeclarativeConfig({
server_name: 'x',
not_a_real_key: true,
});

sinon.assert.calledOnce(warn);
assert.match(warn.firstCall.args.join(' '), /unrecognized.*not_a_real_key/);
});

it('warns on a type mismatch and keeps the default', function () {
const instrumentation = makeInstrumentation();
instrumentation.applyDeclarativeConfig({
require_parent_for_outgoing_spans: 'yes',
});

assert.strictEqual(
instrumentation.getConfig().requireParentforOutgoingSpans,
undefined
);
sinon.assert.calledOnce(warn);
assert.match(
warn.firstCall.args.join(' '),
/require_parent_for_outgoing_spans.*expected boolean/
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@
"tdd": "npm run tdd:node",
"tdd:node": "npm run test -- --watch-extensions ts --watch",
"tdd:browser": "karma start",
"test:cjs": "nyc mocha 'test/**/*.test.ts' --exclude 'test/browser/**/*.ts'",
"test:esm": "nyc node --experimental-loader=./hook.mjs ../../../node_modules/mocha/bin/mocha 'test/node/*.test.mjs'",
"test": "npm run test:cjs && npm run test:esm",
"test:cjs": "nyc --silent mocha 'test/**/*.test.ts' --exclude 'test/browser/**/*.ts'",
"test:esm": "nyc --silent --no-clean node --experimental-loader=./hook.mjs ../../../node_modules/mocha/bin/mocha 'test/node/*.test.mjs'",
"test": "npm run test:cjs && npm run test:esm && nyc report",
"test:browser": "karma start --single-run",
"version": "node ../../../scripts/version-update.js",
"watch": "tsc --build --watch tsconfig.json tsconfig.esm.json tsconfig.esnext.json",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

import { diag } from '@opentelemetry/api';

/**
* Typed, null-safe accessor over a parsed declarative config block.
*
* Each getter returns the typed value, or `undefined` when the key is missing or
* has the wrong type. A type mismatch is logged via `diag.warn`; a missing key
* is silent. Getters never throw.
*/
export interface DeclarativeConfigProperties {
getBoolean(key: string): boolean | undefined;
getString(key: string): string | undefined;
getNumber(key: string): number | undefined;
getStringArray(key: string): string[] | undefined;
/**
* Returns a nested accessor for an object-valued key. Repeated calls for the
* same key return the same accessor, so reads and unread-key tracking
* accumulate across calls.
*/
getStructured(key: string): DeclarativeConfigProperties | undefined;
/**
* Returns keys that no getter has read, such as typos or unsupported options.
* Recurses into structured children that were read via {@link getStructured},
* reporting their unread keys with a dotted path (e.g. `http.client.foo`). A
* key whose nested block was never read is reported on its own.
*/
unreadKeys(): string[];
/**
* Warn about the keys returned by {@link unreadKeys}. Convenience for callers
* that want the default warning rather than their own message.
*/
warnUnreadKeys(): void;
}

class DeclarativeConfigPropertiesImpl implements DeclarativeConfigProperties {
private readonly _block: Record<string, unknown>;
private readonly _read = new Set<string>();
private readonly _children = new Map<
string,
DeclarativeConfigPropertiesImpl
>();

constructor(block: Record<string, unknown>) {
this._block = block;
}

getBoolean(key: string): boolean | undefined {
return this._typed(key, 'boolean', v => typeof v === 'boolean');
}

getString(key: string): string | undefined {
return this._typed(key, 'string', v => typeof v === 'string');
}

getNumber(key: string): number | undefined {
return this._typed(
key,
'number',
v => typeof v === 'number' && !Number.isNaN(v)
);
}

getStringArray(key: string): string[] | undefined {
return this._typed(
key,
'string array',
v => Array.isArray(v) && v.every(e => typeof e === 'string')
);
}

getStructured(key: string): DeclarativeConfigProperties | undefined {
const block = this._typed(
key,
'object',
v => typeof v === 'object' && v !== null && !Array.isArray(v)
);
if (block === undefined) {
return undefined;
}
let child = this._children.get(key);
if (child === undefined) {
child = new DeclarativeConfigPropertiesImpl(
block as Record<string, unknown>
);
this._children.set(key, child);
}
return child;
}

unreadKeys(): string[] {
const unread = Object.keys(this._block).filter(k => !this._read.has(k));
// A child read via getStructured is itself "read", so recurse into it and
// report its unread keys with a dotted path.
for (const [key, child] of this._children) {
for (const nested of child.unreadKeys()) {
unread.push(`${key}.${nested}`);
}
}
return unread;
}

warnUnreadKeys(): void {
const unread = this.unreadKeys();
if (unread.length > 0) {
diag.warn(
`ignoring unrecognized declarative config keys: ${unread.join(', ')}`
);
}
}

// Returns the value when the predicate accepts it. A missing key returns
// undefined and no warning; a present value of the wrong type returns undefined
// and a warning. Records the key as read either way.
private _typed<T>(
key: string,
expected: string,
predicate: (v: unknown) => boolean
): T | undefined {
this._read.add(key);
const value = this._block[key];
if (value === undefined || value === null) {
return undefined;
}
if (!predicate(value)) {
diag.warn(
`declarative config key "${key}": expected ${expected}, got ${typeof value}; ignoring`
);
return undefined;
}
return value as T;
}
}

const EMPTY_BLOCK: Record<string, unknown> = {};

/**
* Wrap a parsed config block in a typed accessor. A nullish or non-object block
* yields an empty accessor, so a reader never has to null-check before reading.
*/
export function declarativeConfigProperties(
block: unknown
): DeclarativeConfigProperties {
const isObject =
typeof block === 'object' && block !== null && !Array.isArray(block);
return new DeclarativeConfigPropertiesImpl(
isObject ? (block as Record<string, unknown>) : EMPTY_BLOCK
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ export {
safeExecuteInTheMiddleAsync,
} from './utils';
export { SemconvStability, semconvStabilityFromStr } from './semconvStability';
export type { DeclarativeConfigProperties } from './declarativeConfigProperties';
export { declarativeConfigProperties } from './declarativeConfigProperties';
Loading