From c60a696ddcd03c5b948409439ebf561462be79ab Mon Sep 17 00:00:00 2001 From: Lukas Reining Date: Wed, 8 Oct 2025 22:19:34 +0200 Subject: [PATCH 1/7] feat: implement new tracing hooks Signed-off-by: Lukas Reining --- libs/hooks/open-telemetry/README.md | 151 ++++- libs/hooks/open-telemetry/package-lock.json | 170 ++++++ libs/hooks/open-telemetry/package.json | 19 +- .../open-telemetry/src/lib/conventions.ts | 6 - .../src/lib/metrics/metrics-hook.spec.ts | 64 +- .../src/lib/metrics/metrics-hook.ts | 33 +- .../hooks/open-telemetry/src/lib/otel-hook.ts | 74 ++- .../open-telemetry/src/lib/traces/index.ts | 2 +- .../src/lib/traces/tracing-hook.spec.ts | 573 +++++++++++++----- .../src/lib/traces/tracing-hook.ts | 46 -- .../src/lib/traces/tracing-hooks.ts | 148 +++++ package-lock.json | 318 +++++++++- package.json | 10 +- release-please-config.json | 3 +- 14 files changed, 1309 insertions(+), 308 deletions(-) create mode 100644 libs/hooks/open-telemetry/package-lock.json delete mode 100644 libs/hooks/open-telemetry/src/lib/traces/tracing-hook.ts create mode 100644 libs/hooks/open-telemetry/src/lib/traces/tracing-hooks.ts diff --git a/libs/hooks/open-telemetry/README.md b/libs/hooks/open-telemetry/README.md index 5d741b922..5cc59b0ed 100644 --- a/libs/hooks/open-telemetry/README.md +++ b/libs/hooks/open-telemetry/README.md @@ -1,6 +1,6 @@ # OpenTelemetry Hooks -The OpenTelemetry hooks for OpenFeature provide a [spec compliant][otel-spec] way to automatically add feature flag evaluation information to traces and metrics. +The OpenTelemetry hooks for OpenFeature provide a [spec compliant][otel-semconv] way to automatically add feature flag evaluation information to traces, logs, and metrics. Since feature flags are dynamic and affect runtime behavior, it’s important to collect relevant feature flag telemetry signals. These can be used to determine the impact a feature has on application behavior, enabling enhanced observability use cases, such as A/B testing or progressive feature releases. @@ -13,16 +13,43 @@ $ npm install @openfeature/open-telemetry-hooks ### Peer dependencies Confirm that the following peer dependencies are installed. +If you use the `MetricsHook`, `SpanEventHook` or `SpanHook`, you need to install: ``` -$ npm install @openfeature/server-sdk @opentelemetry/api +$ npm install @openfeature/core @opentelemetry/api ``` +For the `EventHook`, you also need to install the OpenTelemetry logs SDK: + +``` +$ npm install @opentelemetry/sdk-logs +``` + +> [!NOTE] +> For the hooks to work, you must have the OpenTelemetry SDK configured in your application. +> Please refer to the [OpenTelemetry documentation](https://opentelemetry.io/docs/instrumentation/js/) for more information on setting up OpenTelemetry in your application. +> You need to set up the [tracing SDK][otel-tracing-js] for `SpanHook` and `SpanEventHook`, the [metrics SDK][[otel-metrics-js]] for `MetricsHook`, and the [logs SDK][[otel-logs-js]] for `EventHook`. + ## Hooks -### TracingHook +### EventHook + +This hook logs evaluation events to OpenTelemetry using an [EventLogger][otel-logs]. +These are logged even if there is no active span. +This is useful for exporting evaluation events to a backend that supports [OpenTelemetry log events][otel-logs]. +**Note:** Log Events are the recommended approach for capturing feature flag evaluation data. -This hook adds a [span event](https://opentelemetry.io/docs/concepts/signals/traces/#span-events) for each feature flag evaluation. +### SpanEventHook + +This hook adds evaluation [span events][otel-span-events] to the current active span. +This is useful for associating evaluation events with a trace. +If there is no active span, the event is not logged. +**Note:** [Span events are being deprecated in OTEL][span-event-deprecation-otep] in favor of [using log events via `EventHook`](#eventhook). + +### SpanHook + +This hook creates a new [span][otel-span] for each flag evaluation and sets the evaluation details as [span attributes][otel-span-attributes]. +This is useful for tracing flag evaluations as part of a larger trace. ### MetricsHook @@ -36,52 +63,103 @@ This hook performs metric collection by tapping into various hook stages. Below ## Usage OpenFeature provides various ways to register hooks. The location that a hook is registered affects when the hook is run. -It's recommended to register both the `TracingHook` and `MetricsHook` globally in most situations, but it's possible to only enable the hook on specific clients. -You should **never** register these hooks both globally and on a client. +It's recommended to register the desired hooks globally in most situations, but it's possible to only enable specific hooks on individual clients. +You should **never** register the same hook type both globally and on a client. More information on hooks can be found in the [OpenFeature documentation][hook-concept]. ### Register Globally -The `TracingHook` and `MetricsHook` can both be set on the OpenFeature singleton. +The hooks can be set on the OpenFeature singleton. This will ensure that every flag evaluation will always generate the applicable telemetry signals. ```typescript -import { OpenFeature } from '@openfeature/server-sdk'; -import { TracingHook } from '@openfeature/open-telemetry-hooks'; +import { OpenFeature } from '@openfeature/core'; +import { EventHook, MetricsHook } from '@openfeature/open-telemetry-hooks'; -OpenFeature.addHooks(new TracingHook()); +OpenFeature.addHooks(new EventHook(), new MetricsHook()); ``` ### Register Per Client - The `TracingHook` and `MetricsHook` can both be set on an individual client. This should only be done if it wasn't set globally and other clients shouldn't use this hook. - Setting the hook on the client will ensure that every flag evaluation performed by this client will always generate the applicable telemetry signals. +The hooks can be set on an individual client. This should only be done if they weren't set globally and other clients shouldn't use these hooks. +Setting the hooks on the client will ensure that every flag evaluation performed by this client will always generate the applicable telemetry signals. ```typescript -import { OpenFeature } from '@openfeature/server-sdk'; -import { MetricsHook } from '@openfeature/open-telemetry-hooks'; +import { OpenFeature } from '@openfeature/core'; +import { SpanHook, MetricsHook } from '@openfeature/open-telemetry-hooks'; +import { SpanEventHook } from './tracing-hooks'; const client = OpenFeature.getClient('my-app'); -client.addHooks(new MetricsHook()); +client.addHooks(new SpanEventHook(), new MetricsHook()); ``` -### Custom Attributes +### Hook Selection Guide + +Choose the appropriate hook(s) based on your observability needs: + +- **EventHook**: Recommended for future use cases. Logs evaluation events that can be backends supporting [OTEL Logs][otel-logs]. +- **SpanEventHook**: Span events are being deprecated. Use only if your backend supports span events and you cannot use `EventHook`. +- **SpanHook**: Use when you want dedicated spans for each evaluation in your traces. +- **MetricsHook**: Use alongside any of the above when you need metrics about evaluation performance. -Custom attributes can be extracted from [flag metadata](https://openfeature.dev/specification/types#flag-metadata) by supplying a `attributeMapper` in the `MetricsHookOptions` or `TracingHookOptions`. +### Hook Options -In the case of the `MetricsHook`, these will be added to the `feature_flag.evaluation_success_total` metric. -The `TracingHook` adds them as [span event attributes](https://opentelemetry.io/docs/instrumentation/js/manual/#span-events). +All hooks support the following options via `OpenTelemetryHookOptions`: + +#### Custom Attributes + +Custom attributes can be extracted from [flag metadata](https://openfeature.dev/specification/types#flag-metadata) by supplying an `attributeMapper`: ```typescript -// configure an attributeMapper function for a custom property -const attributeMapper: AttributeMapper = (flagMetadata) => { - return { - myCustomAttribute: flagMetadata.someFlagMetadataField, - }; -}; -const metricsHook = new MetricsHook({ attributeMapper }); -const tracingHook = new TracingHook({ attributeMapper }); +import { EventHook } from '@openfeature/open-telemetry-hooks'; + +const attributeMapper = (flagMetadata) => ({ + myCustomAttribute: flagMetadata.someFlagMetadataField, +}); + +const eventHook = new EventHook({ attributeMapper }); +``` + +#### Exclude Attributes + +Exclude specific attributes from being added to telemetry events: + +```typescript +import { EventHook } from '@openfeature/open-telemetry-hooks'; +import { TelemetryAttribute } from '@openfeature/core'; + +const eventHook = new EventHook({ + excludeAttributes: [TelemetryAttribute.VALUE, 'sensitive_value'] +}); +``` + +#### Exclude Exceptions + +Prevent exceptions from being recorded: + +```typescript +import { SpanHook } from '@openfeature/open-telemetry-hooks'; + +const spanHook = new SpanHook({ excludeExceptions: true }); +``` + +#### Event Mutation + +Transform events before they are emitted: + +```typescript +import { EventHook } from '@openfeature/open-telemetry-hooks'; + +const eventMutator = (event) => ({ + ...event, + attributes: { + ...event.attributes, + environment: 'production' + } +}); + +const eventHook = new EventHook({ eventMutator }); ``` ## Development @@ -94,5 +172,22 @@ Run `nx package hooks-open-telemetry` to build the library. Run `nx test hooks-open-telemetry` to execute the unit tests via [Jest](https://jestjs.io). -[otel-spec]: https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/ +[otel-span]: https://opentelemetry.io/docs/concepts/signals/traces/#spans + +[otel-span-attributes]: https://opentelemetry.io/docs/concepts/signals/traces/#attributes + +[otel-span-events]: https://opentelemetry.io/docs/concepts/signals/traces/#span-events + +[otel-logs]: https://opentelemetry.io/docs/concepts/signals/logs + +[otel-semconv]: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + [hook-concept]: https://openfeature.dev/docs/reference/concepts/hooks + +[span-event-deprecation-otep]: https://github.com/open-telemetry/opentelemetry-specification/blob/fbcd7a3126a545debd9e6e5c69b7b67d4ef1c156/oteps/4430-span-event-api-deprecation-plan.md + +[otel-tracing-js]: https://opentelemetry.io/docs/languages/js/instrumentation/#initialize-tracing + +[otel-metrics-js]: https://opentelemetry.io/docs/languages/js/instrumentation/#initialize-metrics + +[otel-logs-js]: https://opentelemetry.io/docs/languages/js/instrumentation/#logsl diff --git a/libs/hooks/open-telemetry/package-lock.json b/libs/hooks/open-telemetry/package-lock.json new file mode 100644 index 000000000..7b8ba770a --- /dev/null +++ b/libs/hooks/open-telemetry/package-lock.json @@ -0,0 +1,170 @@ +{ + "name": "@openfeature/open-telemetry-hooks", + "version": "0.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openfeature/open-telemetry-hooks", + "version": "0.4.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.37.0" + }, + "devDependencies": { + "@opentelemetry/context-async-hooks": "^2.1.0", + "@opentelemetry/sdk-logs": "^0.205.0", + "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/sdk-trace-node": "^2.1.0" + }, + "peerDependencies": { + "@openfeature/core": "^1.9.1", + "@opentelemetry/api": "^1.3.0", + "@opentelemetry/api-logs": "^0.205.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api-logs": { + "optional": true + } + } + }, + "node_modules/@openfeature/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.9.1.tgz", + "integrity": "sha512-YySPtH4s/rKKnHRU0xyFGrqMU8XA+OIPNWDrlEFxE6DCVWCIrxE5YpiB94YD2jMFn6SSdA0cwQ8vLkCkl8lm8A==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.1.0.tgz", + "integrity": "sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.1.0.tgz", + "integrity": "sha512-SvVlBFc/jI96u/mmlKm86n9BbTCbQ35nsPoOohqJX6DXH92K0kTe73zGY5r8xoI1QkjR9PizszVJLzMC966y9Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.1.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz", + "integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + } + } +} diff --git a/libs/hooks/open-telemetry/package.json b/libs/hooks/open-telemetry/package.json index 4098795ec..129b18179 100644 --- a/libs/hooks/open-telemetry/package.json +++ b/libs/hooks/open-telemetry/package.json @@ -15,7 +15,22 @@ "current-version": "echo $npm_package_version" }, "peerDependencies": { - "@openfeature/server-sdk": "^1.13.0", - "@opentelemetry/api": ">=1.3.0" + "@openfeature/core": "^1.9.1", + "@opentelemetry/api": "^1.3.0", + "@opentelemetry/api-logs": "^0.205.0" + }, + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.37.0" + }, + "devDependencies": { + "@opentelemetry/context-async-hooks": "^2.1.0", + "@opentelemetry/sdk-logs": "^0.205.0", + "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/sdk-trace-node": "^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api-logs": { + "optional": true + } } } diff --git a/libs/hooks/open-telemetry/src/lib/conventions.ts b/libs/hooks/open-telemetry/src/lib/conventions.ts index 43636d5b4..2bc71d8f8 100644 --- a/libs/hooks/open-telemetry/src/lib/conventions.ts +++ b/libs/hooks/open-telemetry/src/lib/conventions.ts @@ -1,4 +1,3 @@ -// see: https://opentelemetry.io/docs/specs/otel/logs/semantic_conventions/feature-flags/ export const FEATURE_FLAG = 'feature_flag'; export const EXCEPTION_ATTR = 'exception'; @@ -9,8 +8,3 @@ export const ERROR_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_error_total`; export type EvaluationAttributes = { [key: `${typeof FEATURE_FLAG}.${string}`]: string | undefined }; export type ExceptionAttributes = { [EXCEPTION_ATTR]: string }; - -export const KEY_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.key`; -export const PROVIDER_NAME_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.provider_name`; -export const VARIANT_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.variant`; -export const REASON_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.reason`; diff --git a/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.spec.ts b/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.spec.ts index 2d9fd885b..fffc8c9d5 100644 --- a/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.spec.ts +++ b/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.spec.ts @@ -1,18 +1,10 @@ -import type { BeforeHookContext, EvaluationDetails, HookContext } from '@openfeature/server-sdk'; -import { StandardResolutionReasons } from '@openfeature/server-sdk'; +import type { BeforeHookContext, EvaluationDetails, HookContext } from '@openfeature/core'; +import { TelemetryAttribute } from '@openfeature/core'; +import { StandardResolutionReasons } from '@openfeature/core'; import opentelemetry from '@opentelemetry/api'; import type { DataPoint, ScopeMetrics } from '@opentelemetry/sdk-metrics'; import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; -import { - ACTIVE_COUNT_NAME, - ERROR_TOTAL_NAME, - KEY_ATTR, - PROVIDER_NAME_ATTR, - REASON_ATTR, - REQUESTS_TOTAL_NAME, - SUCCESS_TOTAL_NAME, - VARIANT_ATTR, -} from '../conventions'; +import { ACTIVE_COUNT_NAME, ERROR_TOTAL_NAME, REQUESTS_TOTAL_NAME, SUCCESS_TOTAL_NAME } from '../conventions'; import { MetricsHook } from './metrics-hook'; import type { AttributeMapper } from '../otel-hook'; @@ -61,8 +53,8 @@ describe(MetricsHook.name, () => { 0, (point) => point.value === 1 && - point.attributes[KEY_ATTR] === FLAG_KEY && - point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME, + point.attributes[TelemetryAttribute.KEY] === FLAG_KEY && + point.attributes[TelemetryAttribute.PROVIDER] === PROVIDER_NAME, ), ).toBeTruthy(); expect( @@ -72,8 +64,8 @@ describe(MetricsHook.name, () => { 0, (point) => point.value === 1 && - point.attributes[KEY_ATTR] === FLAG_KEY && - point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME, + point.attributes[TelemetryAttribute.KEY] === FLAG_KEY && + point.attributes[TelemetryAttribute.PROVIDER] === PROVIDER_NAME, ), ).toBeTruthy(); }); @@ -108,10 +100,10 @@ describe(MetricsHook.name, () => { 0, (point) => point.value === 1 && - point.attributes[KEY_ATTR] === FLAG_KEY && - point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME && - point.attributes[VARIANT_ATTR] === VARIANT && - point.attributes[REASON_ATTR] === StandardResolutionReasons.STATIC, + point.attributes[TelemetryAttribute.KEY] === FLAG_KEY && + point.attributes[TelemetryAttribute.PROVIDER] === PROVIDER_NAME && + point.attributes[TelemetryAttribute.VARIANT] === VARIANT && + point.attributes[TelemetryAttribute.REASON] === StandardResolutionReasons.STATIC, ), ).toBeTruthy(); }); @@ -141,10 +133,10 @@ describe(MetricsHook.name, () => { 1, (point) => point.value === 1 && - point.attributes[KEY_ATTR] === FLAG_KEY && - point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME && - point.attributes[VARIANT_ATTR] === VALUE.toString() && - point.attributes[REASON_ATTR] === StandardResolutionReasons.STATIC, + point.attributes[TelemetryAttribute.KEY] === FLAG_KEY && + point.attributes[TelemetryAttribute.PROVIDER] === PROVIDER_NAME && + point.attributes[TelemetryAttribute.VARIANT] === VALUE.toString() && + point.attributes[TelemetryAttribute.REASON] === StandardResolutionReasons.STATIC, ), ).toBeTruthy(); }); @@ -195,10 +187,10 @@ describe(MetricsHook.name, () => { 2, (point) => point.value === 1 && - point.attributes[KEY_ATTR] === FLAG_KEY && - point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME && - point.attributes[VARIANT_ATTR] === VARIANT && - point.attributes[REASON_ATTR] === StandardResolutionReasons.STATIC && + point.attributes[TelemetryAttribute.KEY] === FLAG_KEY && + point.attributes[TelemetryAttribute.PROVIDER] === PROVIDER_NAME && + point.attributes[TelemetryAttribute.VARIANT] === VARIANT && + point.attributes[TelemetryAttribute.REASON] === StandardResolutionReasons.STATIC && // custom attributes should be present point.attributes[CUSTOM_ATTR_KEY_1] === CUSTOM_ATTR_VALUE_1 && point.attributes[CUSTOM_ATTR_KEY_2] === CUSTOM_ATTR_VALUE_2, @@ -241,10 +233,10 @@ describe(MetricsHook.name, () => { 3, (point) => point.value === 1 && - point.attributes[KEY_ATTR] === FLAG_KEY && - point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME && - point.attributes[VARIANT_ATTR] === VARIANT && - point.attributes[REASON_ATTR] === StandardResolutionReasons.STATIC, + point.attributes[TelemetryAttribute.KEY] === FLAG_KEY && + point.attributes[TelemetryAttribute.PROVIDER] === PROVIDER_NAME && + point.attributes[TelemetryAttribute.VARIANT] === VARIANT && + point.attributes[TelemetryAttribute.REASON] === StandardResolutionReasons.STATIC, ), ).toBeTruthy(); }); @@ -272,8 +264,8 @@ describe(MetricsHook.name, () => { 1, (point) => point.value === -1 && - point.attributes[KEY_ATTR] === FLAG_KEY && - point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME, + point.attributes[TelemetryAttribute.KEY] === FLAG_KEY && + point.attributes[TelemetryAttribute.PROVIDER] === PROVIDER_NAME, ), ).toBeTruthy(); }); @@ -302,8 +294,8 @@ describe(MetricsHook.name, () => { 0, (point) => point.value === 1 && - point.attributes[KEY_ATTR] === FLAG_KEY && - point.attributes[PROVIDER_NAME_ATTR] === PROVIDER_NAME, + point.attributes[TelemetryAttribute.KEY] === FLAG_KEY && + point.attributes[TelemetryAttribute.PROVIDER] === PROVIDER_NAME, ), ).toBeTruthy(); }); diff --git a/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.ts b/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.ts index 42b2178b7..87187cd8c 100644 --- a/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.ts +++ b/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.ts @@ -1,11 +1,12 @@ -import type { BeforeHookContext, Logger } from '@openfeature/server-sdk'; +import type { BeforeHookContext, Logger } from '@openfeature/core'; +import { TelemetryAttribute } from '@openfeature/core'; import { StandardResolutionReasons, type EvaluationDetails, type FlagValue, - type Hook, + type BaseHook, type HookContext, -} from '@openfeature/server-sdk'; +} from '@openfeature/core'; import type { Attributes, Counter, UpDownCounter } from '@opentelemetry/api'; import { ValueType, metrics } from '@opentelemetry/api'; import type { EvaluationAttributes, ExceptionAttributes } from '../conventions'; @@ -13,12 +14,8 @@ import { ACTIVE_COUNT_NAME, ERROR_TOTAL_NAME, EXCEPTION_ATTR, - KEY_ATTR, - PROVIDER_NAME_ATTR, - REASON_ATTR, REQUESTS_TOTAL_NAME, SUCCESS_TOTAL_NAME, - VARIANT_ATTR, } from '../conventions'; import type { OpenTelemetryHookOptions } from '../otel-hook'; import { OpenTelemetryHook } from '../otel-hook'; @@ -39,7 +36,7 @@ const ERROR_DESCRIPTION = 'feature flag evaluation error counter'; * * See {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/} */ -export class MetricsHook extends OpenTelemetryHook implements Hook { +export class MetricsHook extends OpenTelemetryHook implements BaseHook { protected name = MetricsHook.name; private readonly evaluationActiveUpDownCounter: UpDownCounter; private readonly evaluationRequestCounter: Counter; @@ -72,8 +69,8 @@ export class MetricsHook extends OpenTelemetryHook implements Hook { before(hookContext: BeforeHookContext) { const attributes: EvaluationAttributes = { - [KEY_ATTR]: hookContext.flagKey, - [PROVIDER_NAME_ATTR]: hookContext.providerMetadata.name, + [TelemetryAttribute.KEY]: hookContext.flagKey, + [TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name, }; this.evaluationActiveUpDownCounter.add(1, attributes); this.evaluationRequestCounter.add(1, attributes); @@ -81,26 +78,26 @@ export class MetricsHook extends OpenTelemetryHook implements Hook { after(hookContext: Readonly>, evaluationDetails: EvaluationDetails) { this.evaluationSuccessCounter.add(1, { - [KEY_ATTR]: hookContext.flagKey, - [PROVIDER_NAME_ATTR]: hookContext.providerMetadata.name, - [VARIANT_ATTR]: evaluationDetails.variant ?? evaluationDetails.value?.toString(), - [REASON_ATTR]: evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN, + [TelemetryAttribute.KEY]: hookContext.flagKey, + [TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name, + [TelemetryAttribute.VARIANT]: evaluationDetails.variant ?? evaluationDetails.value?.toString(), + [TelemetryAttribute.REASON]: evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN, ...this.safeAttributeMapper(evaluationDetails?.flagMetadata || {}), }); } error(hookContext: Readonly>, error: unknown) { this.evaluationErrorCounter.add(1, { - [KEY_ATTR]: hookContext.flagKey, - [PROVIDER_NAME_ATTR]: hookContext.providerMetadata.name, + [TelemetryAttribute.KEY]: hookContext.flagKey, + [TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name, [EXCEPTION_ATTR]: (error as Error)?.message || 'Unknown error', }); } finally(hookContext: Readonly>) { this.evaluationActiveUpDownCounter.add(-1, { - [KEY_ATTR]: hookContext.flagKey, - [PROVIDER_NAME_ATTR]: hookContext.providerMetadata.name, + [TelemetryAttribute.KEY]: hookContext.flagKey, + [TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name, }); } } diff --git a/libs/hooks/open-telemetry/src/lib/otel-hook.ts b/libs/hooks/open-telemetry/src/lib/otel-hook.ts index ea8eda98d..018bc273c 100644 --- a/libs/hooks/open-telemetry/src/lib/otel-hook.ts +++ b/libs/hooks/open-telemetry/src/lib/otel-hook.ts @@ -1,23 +1,64 @@ -import type { FlagMetadata, Logger } from '@openfeature/server-sdk'; +import type { + EvaluationDetails, + FlagMetadata, + FlagValue, + HookContext, + Logger, + TelemetryAttribute, +} from '@openfeature/core'; +import { createEvaluationEvent } from '@openfeature/core'; import type { Attributes } from '@opentelemetry/api'; +type EvaluationEvent = { name: string; attributes: Attributes }; +type TelemetryAttributesNames = [keyof typeof TelemetryAttribute][number] | string; + export type AttributeMapper = (flagMetadata: FlagMetadata) => Attributes; +export type EventMutator = (event: EvaluationEvent) => EvaluationEvent; + export type OpenTelemetryHookOptions = { /** * A function that maps OpenFeature flag metadata values to OpenTelemetry attributes. + * This can be used to add custom attributes to the telemetry event. + * Note: This function is applied after the excludeAttributes option. */ attributeMapper?: AttributeMapper; + + /** + * Exclude specific attributes from being added to the telemetry event. + * This is useful for excluding sensitive information, or reducing the size of the event. + * By default, no attributes are excluded. + * Note: This option is applied before the attributeMapper and eventMutator options. + */ + excludeAttributes?: TelemetryAttributesNames[]; + + /** + * If true, unhandled error or promise rejection during flag resolution, or any attached hooks + * will not be recorded on the active span. + * By default, exceptions are recorded on the active span, if there is one. + */ + excludeExceptions?: boolean; + + /** + * Takes a telemetry event and returns a telemetry event. + * This can be used to filter out attributes that are not needed or to add additional attributes. + * Note: This function is applied after the attributeMapper and excludeAttributes options. + */ + eventMutator?: EventMutator; }; /** * Base class that does some logging and safely wraps the AttributeMapper. */ export abstract class OpenTelemetryHook { - protected safeAttributeMapper: AttributeMapper; protected abstract name: string; - constructor(options?: OpenTelemetryHookOptions, logger?: Logger) { + protected attributesToExclude: TelemetryAttributesNames[]; + protected excludeExceptions: boolean; + protected safeAttributeMapper: AttributeMapper; + protected safeEventMutator: EventMutator; + + protected constructor(options?: OpenTelemetryHookOptions, logger?: Logger) { this.safeAttributeMapper = (flagMetadata: FlagMetadata) => { try { return options?.attributeMapper?.(flagMetadata) || {}; @@ -26,5 +67,32 @@ export abstract class OpenTelemetryHook { return {}; } }; + this.safeEventMutator = (event: EvaluationEvent) => { + try { + return options?.eventMutator?.(event) ?? event; + } catch (err) { + logger?.debug(`${this.name}: error in eventMutator, ${err.message}, ${err.stack}`); + return event; + } + }; + this.attributesToExclude = options?.excludeAttributes ?? []; + this.excludeExceptions = options?.excludeExceptions ?? false; + } + + protected toEvaluationEvent( + hookContext: Readonly, + evaluationDetails: EvaluationDetails, + ): EvaluationEvent { + const { name, attributes } = createEvaluationEvent(hookContext, evaluationDetails); + const customAttributes = this.safeAttributeMapper(evaluationDetails.flagMetadata); + + for (const attributeToExclude of this.attributesToExclude) { + delete attributes[attributeToExclude]; + } + + return this.safeEventMutator({ + name, + attributes: { ...attributes, ...customAttributes }, + }); } } diff --git a/libs/hooks/open-telemetry/src/lib/traces/index.ts b/libs/hooks/open-telemetry/src/lib/traces/index.ts index d3b978b86..684bcee07 100644 --- a/libs/hooks/open-telemetry/src/lib/traces/index.ts +++ b/libs/hooks/open-telemetry/src/lib/traces/index.ts @@ -1 +1 @@ -export * from './tracing-hook'; +export * from './tracing-hooks'; diff --git a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts index 89a291289..b73d5b784 100644 --- a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts +++ b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts @@ -1,197 +1,452 @@ -import type { EvaluationDetails, HookContext } from '@openfeature/server-sdk'; -import { MapHookData } from '@openfeature/server-sdk'; +import type { Tracer } from '@opentelemetry/api'; +import { context, trace } from '@opentelemetry/api'; +import { NodeTracerProvider, SimpleSpanProcessor, InMemorySpanExporter } from '@opentelemetry/sdk-trace-node'; +import { logs } from '@opentelemetry/api-logs'; +import { LoggerProvider, SimpleLogRecordProcessor, InMemoryLogRecordExporter } from '@opentelemetry/sdk-logs'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import type { SpanAttributesTracingHookData } from './tracing-hooks'; +import { EventHook } from './tracing-hooks'; +import { SpanEventHook, SpanHook } from './tracing-hooks'; +import type { BaseHook, EvaluationDetails, FlagValue, HookContext } from '@openfeature/core'; +import { StandardResolutionReasons } from '@openfeature/core'; +import { MapHookData } from '@openfeature/core'; +import { + ATTR_EXCEPTION_MESSAGE, + ATTR_EXCEPTION_STACKTRACE, + ATTR_EXCEPTION_TYPE, +} from '@opentelemetry/semantic-conventions'; -const addEvent = jest.fn(); -const recordException = jest.fn(); +describe('OpenTelemetry Hooks', () => { + let tracerProvider: NodeTracerProvider; + let spanProcessor: SimpleSpanProcessor; + let memorySpanExporter: InMemorySpanExporter; + let tracer: Tracer; + let contextManager: AsyncLocalStorageContextManager; -const getActiveSpan = jest.fn(() => ({ addEvent, recordException })); + let loggerProvider: LoggerProvider; + let logProcessor: SimpleLogRecordProcessor; + let memoryLogExporter: InMemoryLogRecordExporter; -jest.mock('@opentelemetry/api', () => ({ - trace: { - getActiveSpan, - }, -})); + let hookContext: HookContext; -// Import must be after the mocks -import { TracingHook } from './tracing-hook'; + beforeAll(() => { + memorySpanExporter = new InMemorySpanExporter(); + spanProcessor = new SimpleSpanProcessor(memorySpanExporter); + tracerProvider = new NodeTracerProvider({ spanProcessors: [spanProcessor] }); + contextManager = new AsyncLocalStorageContextManager().enable(); + context.setGlobalContextManager(contextManager); + trace.setGlobalTracerProvider(tracerProvider); + tracer = tracerProvider.getTracer('test'); -describe('OpenTelemetry Hooks', () => { - const hookContext: HookContext = { - flagKey: 'testFlagKey', - clientMetadata: { - providerMetadata: { - name: 'fake', - }, - name: 'testClient', - }, - providerMetadata: { - name: 'testProvider', - }, - context: {}, - defaultValue: true, - flagValueType: 'boolean', - logger: console, - hookData: new MapHookData(), - }; - - let tracingHook: TracingHook; + memoryLogExporter = new InMemoryLogRecordExporter(); + logProcessor = new SimpleLogRecordProcessor(memoryLogExporter); + loggerProvider = new LoggerProvider({ processors: [logProcessor] }); + logs.setGlobalLoggerProvider(loggerProvider); + + hookContext = { + clientMetadata: { providerMetadata: { name: 'test-provider' }, domain: 'test-client' }, + providerMetadata: { name: 'test-provider' }, + flagKey: 'flag', + flagValueType: 'boolean', + defaultValue: true, + context: {}, + logger: console, + hookData: new MapHookData(), + }; + }); afterEach(() => { - jest.clearAllMocks(); + memorySpanExporter.reset(); + memoryLogExporter.reset(); + contextManager.disable(); + contextManager.enable(); + hookContext.hookData.clear(); }); - describe('after stage', () => { - describe('no attribute mapper', () => { - beforeEach(() => { - tracingHook = new TracingHook(); + describe('EventHook', () => { + it('should log an evaluation event', () => { + const hook: BaseHook = new EventHook(); + const details: EvaluationDetails = { + flagKey: 'flag', + variant: 'on', + value: true, + flagMetadata: {}, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + + hook.before?.(hookContext); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + + const finished = memoryLogExporter.getFinishedLogRecords(); + expect(finished.length).toBe(1); + const logRecord = finished[0]; + + expect(logRecord.eventName).toEqual('feature_flag.evaluation'); + expect(logRecord.attributes).toEqual( + expect.objectContaining({ + 'feature_flag.key': hookContext.flagKey, + 'feature_flag.provider.name': hookContext.providerMetadata.name, + 'feature_flag.result.reason': details.reason?.toLocaleLowerCase(), + 'feature_flag.result.value': details.value, + 'feature_flag.result.variant': details.variant, + }), + ); + }); + + it('should log exception on error', () => { + const hook: BaseHook = new EventHook(); + const error = new Error('fail'); + const details: EvaluationDetails = { + flagKey: 'flag', + variant: 'on', + value: true, + flagMetadata: {}, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + + hook.before?.(hookContext); + hook.error?.(hookContext, error); + hook.finally?.(hookContext, details); + + const finished = memoryLogExporter.getFinishedLogRecords(); + expect(finished.length).toBe(2); + const logRecord = finished[0]; + + expect(logRecord.eventName).toEqual('exception'); + expect(logRecord.attributes).toEqual({ + [ATTR_EXCEPTION_TYPE]: error.name, + [ATTR_EXCEPTION_MESSAGE]: error.message, + [ATTR_EXCEPTION_STACKTRACE]: error.stack, }); + }); + + describe('OpenTelemetryHookOptions', () => { + const details: EvaluationDetails = { + flagKey: 'flag', + variant: 'on', + value: true, + flagMetadata: { foo: 'bar', secret: 'shouldBeExcluded' }, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; - it('should use the variant value on the span event', () => { - const evaluationDetails: EvaluationDetails = { - flagKey: hookContext.flagKey, - value: true, - variant: 'enabled', - flagMetadata: {}, - }; + it('should add custom attribute via attributeMapper', () => { + const hook: BaseHook = new EventHook({ + attributeMapper: (meta) => ({ custom: meta.foo }), + }); + hook.before?.(hookContext); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + const finished = memoryLogExporter.getFinishedLogRecords(); + expect(finished[0]?.attributes?.custom).toBe('bar'); + }); - tracingHook.after(hookContext, evaluationDetails); + it('should exclude attribute via excludeAttributes', () => { + const hook: BaseHook = new EventHook({ + excludeAttributes: ['secret'], + }); + hook.before?.(hookContext); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + const finished = memoryLogExporter.getFinishedLogRecords(); + expect(finished[0]?.attributes?.secret).toBeUndefined(); + }); - expect(addEvent).toBeCalledWith('feature_flag', { - 'feature_flag.key': 'testFlagKey', - 'feature_flag.provider_name': 'testProvider', - 'feature_flag.variant': 'enabled', + it('should mutate event via eventMutator', () => { + const hook: BaseHook = new EventHook({ + eventMutator: (event) => ({ ...event, attributes: { ...event.attributes, mutated: true } }), }); + hook.before?.(hookContext); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + const finished = memoryLogExporter.getFinishedLogRecords(); + expect(finished[0]?.attributes?.mutated).toBe(true); }); - it('should use a stringified value as the variant value on the span event', () => { - const evaluationDetails: EvaluationDetails = { - flagKey: hookContext.flagKey, - value: true, - flagMetadata: {}, - }; + it('should not log exception if excludeExceptions is true', () => { + const hook: BaseHook = new EventHook({ excludeExceptions: true }); + const error = new Error('fail'); + hook.before?.(hookContext); + hook.error?.(hookContext, error); + hook.finally?.(hookContext, details); + const finished = memoryLogExporter.getFinishedLogRecords(); + // Should not log exception event, only the finally evaltion event + expect(finished.length).toBe(1); + }); + }); + }); - tracingHook.after(hookContext, evaluationDetails); + describe('SpanEventHook', () => { + it('should add an event to the active span', () => { + const hook: BaseHook = new SpanEventHook(); + const details: EvaluationDetails = { + flagKey: 'flag', + variant: 'on', + value: true, + flagMetadata: {}, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; - expect(addEvent).toBeCalledWith('feature_flag', { - 'feature_flag.key': 'testFlagKey', - 'feature_flag.provider_name': 'testProvider', - 'feature_flag.variant': 'true', - }); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.before?.(hookContext); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + span.end(); }); + const finished = memorySpanExporter.getFinishedSpans(); + expect(finished.length).toBe(1); + const events = finished[0].events; + expect(events.length).toBe(1); - it('should set the value without extra quotes if value is already a string', () => { - const evaluationDetails: EvaluationDetails = { - flagKey: hookContext.flagKey, - value: 'already-string', - flagMetadata: {}, - }; - tracingHook.after(hookContext, evaluationDetails); + expect(events[0].name).toEqual('feature_flag.evaluation'); + expect(events[0].attributes).toEqual( + expect.objectContaining({ + 'feature_flag.key': hookContext.flagKey, + 'feature_flag.provider.name': hookContext.providerMetadata.name, + 'feature_flag.result.reason': details.reason?.toLocaleLowerCase(), + 'feature_flag.result.value': details.value, + 'feature_flag.result.variant': details.variant, + }), + ); + }); - expect(addEvent).toBeCalledWith('feature_flag', { - 'feature_flag.key': 'testFlagKey', - 'feature_flag.provider_name': 'testProvider', - 'feature_flag.variant': 'already-string', - }); + it('should record exception on error', () => { + const hook: BaseHook = new SpanEventHook(); + const error = new Error('fail'); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.before?.(hookContext); + hook.error?.(hookContext, error); + span.end(); }); + const finished = memorySpanExporter.getFinishedSpans(); + expect(finished.length).toBe(1); - it('should not call addEvent because there is no active span', () => { - getActiveSpan.mockReturnValueOnce(undefined); - const evaluationDetails: EvaluationDetails = { - flagKey: hookContext.flagKey, - value: true, - variant: 'enabled', - flagMetadata: {}, - }; + const events = finished[0].events; + expect(events.length).toBe(1); - tracingHook.after(hookContext, evaluationDetails); - expect(addEvent).not.toBeCalled(); + expect(events[0].name).toEqual('exception'); + expect(events[0].attributes).toEqual({ + [ATTR_EXCEPTION_TYPE]: error.name, + [ATTR_EXCEPTION_MESSAGE]: error.message, + [ATTR_EXCEPTION_STACKTRACE]: error.stack, }); }); - describe('attribute mapper configured', () => { - describe('no error in mapper', () => { - beforeEach(() => { - tracingHook = new TracingHook({ - attributeMapper: (flagMetadata) => { - return { - customAttr1: flagMetadata.metadata1, - customAttr2: flagMetadata.metadata2, - customAttr3: flagMetadata.metadata3, - }; - }, - }); - }); - - it('should run the attribute mapper to add custom attributes, if set', () => { - const evaluationDetails: EvaluationDetails = { - flagKey: hookContext.flagKey, - value: true, - variant: 'enabled', - flagMetadata: { - metadata1: 'one', - metadata2: 2, - metadata3: true, - }, - }; - - tracingHook.after(hookContext, evaluationDetails); - - expect(addEvent).toBeCalledWith('feature_flag', { - 'feature_flag.key': 'testFlagKey', - 'feature_flag.provider_name': 'testProvider', - 'feature_flag.variant': 'enabled', - customAttr1: 'one', - customAttr2: 2, - customAttr3: true, - }); - }); - }); - - describe('error in mapper', () => { - beforeEach(() => { - tracingHook = new TracingHook({ - attributeMapper: () => { - throw new Error('fake error'); - }, - }); - }); - - it('should no-op', () => { - const evaluationDetails: EvaluationDetails = { - flagKey: hookContext.flagKey, - value: true, - variant: 'enabled', - flagMetadata: { - metadata1: 'one', - metadata2: 2, - metadata3: true, - }, - }; - - tracingHook.after(hookContext, evaluationDetails); - - expect(addEvent).toBeCalledWith('feature_flag', { - 'feature_flag.key': 'testFlagKey', - 'feature_flag.provider_name': 'testProvider', - 'feature_flag.variant': 'enabled', - }); + describe('OpenTelemetryHookOptions', () => { + const details: EvaluationDetails = { + flagKey: 'flag', + variant: 'on', + value: true, + flagMetadata: { foo: 'bar', secret: 'shouldBeExcluded' }, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + + it('should add custom attribute via attributeMapper', () => { + const hook: BaseHook = new SpanEventHook({ + attributeMapper: (meta) => ({ custom: meta.foo }), + }); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.before?.(hookContext); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + span.end(); + }); + const finished = memorySpanExporter.getFinishedSpans(); + const attrs = finished[0].events[0].attributes; + expect(attrs?.custom).toBe('bar'); + }); + + it('should exclude attribute via excludeAttributes', () => { + const hook: BaseHook = new SpanEventHook({ + excludeAttributes: ['secret'], + }); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.before?.(hookContext, details); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + span.end(); }); + const finished = memorySpanExporter.getFinishedSpans(); + const attrs = finished[0].events[0].attributes; + expect(attrs?.secret).toBeUndefined(); + }); + + it('should mutate event via eventMutator', () => { + const hook: BaseHook = new SpanEventHook({ + eventMutator: (event) => ({ ...event, attributes: { ...event.attributes, mutated: true } }), + }); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.finally?.(hookContext, details); + span.end(); + }); + const finished = memorySpanExporter.getFinishedSpans(); + const attrs = finished[0].events[0].attributes; + expect(attrs?.mutated).toBe(true); + }); + + it('should not record exception if excludeExceptions is true', () => { + const hook: BaseHook = new SpanEventHook({ excludeExceptions: true }); + const error = new Error('fail'); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.before?.(hookContext); + hook.error?.(hookContext, error); + span.end(); + }); + const finished = memorySpanExporter.getFinishedSpans(); + // Should not record exception event + expect(finished[0].events.length).toBe(0); }); }); }); - describe('error stage', () => { - const testError = new Error(); + describe('SpanHook', () => { + it('should create a span for evaluation and set attributes', () => { + const hook: BaseHook = new SpanHook(); + const details: EvaluationDetails = { + flagKey: 'flag', + variant: 'on', + value: true, + flagMetadata: {}, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.before?.(hookContext); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + span.end(); + }); + const finished = memorySpanExporter.getFinishedSpans(); + expect(finished.length).toBe(2); // One for evaluation, one for test-span + const finishedSpan = finished[0]; // The evaluation span is ended first + + expect(finishedSpan.name).toEqual('feature_flag.evaluation'); + expect(finishedSpan.attributes).toEqual( + expect.objectContaining({ + 'feature_flag.key': hookContext.flagKey, + 'feature_flag.provider.name': hookContext.providerMetadata.name, + 'feature_flag.result.reason': details.reason?.toLocaleLowerCase(), + 'feature_flag.result.value': details.value, + 'feature_flag.result.variant': details.variant, + }), + ); + }); + + it('should create a span and record exception on error', () => { + const hook: BaseHook = new SpanHook(); + + const error = new Error('fail'); + const details: EvaluationDetails = { + flagKey: 'flag', + variant: 'on', + value: true, + flagMetadata: {}, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.before?.(hookContext); + hook.error?.(hookContext, error); + hook.finally?.(hookContext, details); + span.end(); + }); + + const finished = memorySpanExporter.getFinishedSpans(); + expect(finished.length).toBe(2); // One for evaluation, one for test-span + const finishedSpan = finished[0]; // The evaluation span is ended first + expect(finishedSpan.name).toEqual('feature_flag.evaluation'); - it('should call recordException with a test error', () => { - tracingHook.error(hookContext, testError); - expect(recordException).toBeCalledWith(testError); + const events = finishedSpan.events; + expect(events.length).toBe(1); + + expect(events[0].name).toEqual('exception'); + expect(events[0].attributes).toEqual({ + [ATTR_EXCEPTION_TYPE]: error.name, + [ATTR_EXCEPTION_MESSAGE]: error.message, + [ATTR_EXCEPTION_STACKTRACE]: error.stack, + }); }); - it('should not call recordException because there is no active span', () => { - getActiveSpan.mockReturnValueOnce(undefined); - tracingHook.error(hookContext, testError); - expect(recordException).not.toBeCalled(); + describe('OpenTelemetryHookOptions', () => { + const details: EvaluationDetails = { + flagKey: 'flag', + variant: 'on', + value: true, + flagMetadata: { foo: 'bar', secret: 'shouldBeExcluded' }, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + + it('should add custom attribute via attributeMapper', () => { + const hook: BaseHook = new SpanHook({ + attributeMapper: (meta) => ({ custom: meta.foo }), + }); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.before?.(hookContext); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + span.end(); + }); + const finished = memorySpanExporter.getFinishedSpans(); + const evalSpan = finished[0]; + expect(evalSpan.attributes?.custom).toBe('bar'); + }); + + it('should exclude attribute via excludeAttributes', () => { + const hook: BaseHook = new SpanHook({ + excludeAttributes: ['secret'], + }); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.before?.(hookContext); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + span.end(); + }); + const finished = memorySpanExporter.getFinishedSpans(); + const evalSpan = finished[0]; + expect(evalSpan.attributes?.secret).toBeUndefined(); + }); + + it('should mutate event via eventMutator', () => { + const hook: BaseHook = new SpanHook({ + eventMutator: (event) => ({ ...event, attributes: { ...event.attributes, mutated: true } }), + }); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.before?.(hookContext); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + span.end(); + }); + const finished = memorySpanExporter.getFinishedSpans(); + const evalSpan = finished[0]; + expect(evalSpan.attributes?.mutated).toBe(true); + }); + + it('should not record exception if excludeExceptions is true', () => { + const hook: BaseHook = new SpanHook({ excludeExceptions: true }); + const error = new Error('fail'); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + hook.before?.(hookContext); + hook.error?.(hookContext, error); + hook.finally?.(hookContext, details); + span.end(); + }); + const finished = memorySpanExporter.getFinishedSpans(); + const evalSpan = finished[0]; + // Should not record exception event + expect(evalSpan.events.length).toBe(0); + }); }); }); }); diff --git a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.ts b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.ts deleted file mode 100644 index df509ca17..000000000 --- a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Hook, HookContext, EvaluationDetails, FlagValue, Logger } from '@openfeature/server-sdk'; -import { trace } from '@opentelemetry/api'; -import { FEATURE_FLAG, KEY_ATTR, PROVIDER_NAME_ATTR, VARIANT_ATTR } from '../conventions'; -import type { OpenTelemetryHookOptions } from '../otel-hook'; -import { OpenTelemetryHook } from '../otel-hook'; - -export type TracingHookOptions = OpenTelemetryHookOptions; - -/** - * A hook that adds conventionally-compliant span events to feature flag evaluations. - * - * See {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/} - */ -export class TracingHook extends OpenTelemetryHook implements Hook { - protected name = TracingHook.name; - - constructor(options?: TracingHookOptions, logger?: Logger) { - super(options, logger); - } - - after(hookContext: HookContext, evaluationDetails: EvaluationDetails) { - const currentTrace = trace.getActiveSpan(); - if (currentTrace) { - let variant = evaluationDetails.variant; - - if (!variant) { - if (typeof evaluationDetails.value === 'string') { - variant = evaluationDetails.value; - } else { - variant = JSON.stringify(evaluationDetails.value); - } - } - - currentTrace.addEvent(FEATURE_FLAG, { - [KEY_ATTR]: hookContext.flagKey, - [PROVIDER_NAME_ATTR]: hookContext.providerMetadata.name, - [VARIANT_ATTR]: variant, - ...this.safeAttributeMapper(evaluationDetails.flagMetadata), - }); - } - } - - error(_: HookContext, err: Error) { - trace.getActiveSpan()?.recordException(err); - } -} diff --git a/libs/hooks/open-telemetry/src/lib/traces/tracing-hooks.ts b/libs/hooks/open-telemetry/src/lib/traces/tracing-hooks.ts new file mode 100644 index 000000000..ca4819380 --- /dev/null +++ b/libs/hooks/open-telemetry/src/lib/traces/tracing-hooks.ts @@ -0,0 +1,148 @@ +import type { BaseHook, HookContext, EvaluationDetails, FlagValue, Logger } from '@openfeature/core'; +import type { Attributes, Exception, Span } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import type { Logger as OTELLogger, LogRecord } from '@opentelemetry/api-logs'; +import { logs } from '@opentelemetry/api-logs'; +import { + ATTR_EXCEPTION_MESSAGE, + ATTR_EXCEPTION_STACKTRACE, + ATTR_EXCEPTION_TYPE, +} from '@opentelemetry/semantic-conventions'; +import type { OpenTelemetryHookOptions } from '../otel-hook'; +import { OpenTelemetryHook } from '../otel-hook'; + +const LIBRARY_NAME = '@openfeature/open-telemetry-hooks'; +const LIBRARY_VERSION = '0.4.0'; //x-release-please-version + +/** + * A hook that logs evaluation events to OpenTelemetry using an EventLogger. + * This is useful for exporting evaluation events to a backend that supports + * OpenTelemetry events. + */ +export class EventHook extends OpenTelemetryHook implements BaseHook { + protected name = EventHook.name; + private eventLogger: OTELLogger; + + constructor(options?: OpenTelemetryHookOptions, logger?: Logger) { + super(options, logger); + this.eventLogger = logs.getLogger(LIBRARY_NAME, LIBRARY_VERSION); + } + + finally(hookContext: Readonly, evaluationDetails: EvaluationDetails) { + const { name, attributes } = this.toEvaluationEvent(hookContext, evaluationDetails); + this.eventLogger.emit({ eventName: name, attributes: attributes }); + } + + error(_: HookContext, err: Error) { + if (!this.excludeExceptions) { + this.eventLogger.emit(this.toExceptionLogEvent(err)); + } + } + + /** + * Converts an exception to an OpenTelemetry log event. + * The event is compatible to https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-logs/ + * The mapping code is adapted from the OpenTelemetry JS SDK: + * https://github.com/open-telemetry/opentelemetry-js/blob/09bf31eb966bab627e76a6c5c05c6e51ccd2f387/packages/opentelemetry-sdk-trace-base/src/Span.ts#L330 + * @private + */ + private toExceptionLogEvent(exception: Exception): LogRecord { + const attributes: Attributes = {}; + if (typeof exception === 'string') { + attributes[ATTR_EXCEPTION_MESSAGE] = exception; + } else if (exception) { + if (exception.code) { + attributes[ATTR_EXCEPTION_TYPE] = exception.code.toString(); + } else if (exception.name) { + attributes[ATTR_EXCEPTION_TYPE] = exception.name; + } + if (exception.message) { + attributes[ATTR_EXCEPTION_MESSAGE] = exception.message; + } + if (exception.stack) { + attributes[ATTR_EXCEPTION_STACKTRACE] = exception.stack; + } + } + + return { + eventName: 'exception', + attributes, + }; + } +} + +/** + * A hook that adds evaluation events to the current active span. + * This is useful for associating evaluation events with a trace. + * If there is no active span, the event is not logged. + * Span events are being deprecated in favor of using log events. + */ +export class SpanEventHook extends OpenTelemetryHook implements BaseHook { + protected name = SpanEventHook.name; + + constructor(options?: OpenTelemetryHookOptions, logger?: Logger) { + super(options, logger); + } + + finally(hookContext: Readonly, evaluationDetails: EvaluationDetails) { + const currentTrace = trace.getActiveSpan(); + if (!currentTrace) { + return; + } + + const { name, attributes } = this.toEvaluationEvent(hookContext, evaluationDetails); + + currentTrace.addEvent(name, attributes); + } + + error(_: HookContext, err: Error) { + if (!this.excludeExceptions) { + trace.getActiveSpan()?.recordException(err); + } + } +} + +const tracer = trace.getTracer(LIBRARY_NAME, LIBRARY_VERSION); +const HookContextSpanKey = Symbol('evaluation_span'); +export type SpanAttributesTracingHookData = { [HookContextSpanKey]: Span }; + +/** + * A hook that creates a new span for each flag evaluation and sets the evaluation + * details as span attributes. + * This is useful for tracing flag evaluations as part of a larger trace. + * If there is no active span, a new root span is created. + */ +export class SpanHook extends OpenTelemetryHook implements BaseHook { + protected name = SpanHook.name; + + constructor(options?: OpenTelemetryHookOptions, logger?: Logger) { + super(options, logger); + } + + before(hookContext: HookContext) { + const evaluationSpan = tracer.startSpan('feature_flag.evaluation'); + hookContext.hookData.set(HookContextSpanKey, evaluationSpan); + } + + finally( + hookContext: Readonly>, + evaluationDetails: EvaluationDetails, + ) { + const currentSpan = hookContext.hookData.get(HookContextSpanKey); + if (!currentSpan) { + return; + } + + const { attributes } = this.toEvaluationEvent(hookContext, evaluationDetails); + + currentSpan.setAttributes(attributes); + currentSpan.end(); + } + + error(hookContext: Readonly>, err: Error) { + if (!this.excludeExceptions) { + const currentSpan = hookContext.hookData.get(HookContextSpanKey) ?? trace.getActiveSpan(); + currentSpan?.recordException(err); + } + } +} diff --git a/package-lock.json b/package-lock.json index bfd2e1eec..2d12d7d27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,9 @@ "@flipt-io/flipt-client-js": "^0.2.0", "@growthbook/growthbook": "^1.3.1", "@grpc/grpc-js": "^1.9.13", - "@opentelemetry/api": "^1.3.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.205.0", + "@opentelemetry/semantic-conventions": "^1.37.0", "@protobuf-ts/grpc-transport": "^2.9.0", "@protobuf-ts/runtime-rpc": "^2.9.0", "@swc/helpers": "0.5.17", @@ -51,7 +53,11 @@ "@openfeature/core": "^1.9.1", "@openfeature/server-sdk": "^1.19.0", "@openfeature/web-sdk": "^1.6.2", - "@opentelemetry/sdk-metrics": "^1.15.0", + "@opentelemetry/context-async-hooks": "^2.1.0", + "@opentelemetry/sdk-logs": "^0.205.0", + "@opentelemetry/sdk-metrics": "^1.30.1", + "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/sdk-trace-node": "^2.1.0", "@swc-node/register": "~1.10.0", "@swc/cli": "~0.7.0", "@swc/core": "1.12.9", @@ -3780,6 +3786,31 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.1.0.tgz", + "integrity": "sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/core": { "version": "1.30.1", "dev": true, @@ -3794,6 +3825,16 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@opentelemetry/resources": { "version": "1.30.1", "dev": true, @@ -3809,6 +3850,67 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/sdk-metrics": { "version": "1.30.1", "dev": true, @@ -3824,10 +3926,96 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.1.0.tgz", + "integrity": "sha512-SvVlBFc/jI96u/mmlKm86n9BbTCbQ35nsPoOohqJX6DXH92K0kTe73zGY5r8xoI1QkjR9PizszVJLzMC966y9Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.1.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz", + "integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==", + "license": "Apache-2.0", "engines": { "node": ">=14" } @@ -20265,11 +20453,34 @@ "@opentelemetry/api": { "version": "1.9.0" }, + "@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "requires": { + "@opentelemetry/api": "^1.3.0" + } + }, + "@opentelemetry/context-async-hooks": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.1.0.tgz", + "integrity": "sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg==", + "dev": true, + "requires": {} + }, "@opentelemetry/core": { "version": "1.30.1", "dev": true, "requires": { "@opentelemetry/semantic-conventions": "1.28.0" + }, + "dependencies": { + "@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "dev": true + } } }, "@opentelemetry/resources": { @@ -20278,6 +20489,46 @@ "requires": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" + }, + "dependencies": { + "@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "dev": true + } + } + }, + "@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "dev": true, + "requires": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "dependencies": { + "@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "dev": true, + "requires": { + "@opentelemetry/semantic-conventions": "^1.29.0" + } + }, + "@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "dev": true, + "requires": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + } + } } }, "@opentelemetry/sdk-metrics": { @@ -20288,9 +20539,64 @@ "@opentelemetry/resources": "1.30.1" } }, + "@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "dev": true, + "requires": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "dependencies": { + "@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "dev": true, + "requires": { + "@opentelemetry/semantic-conventions": "^1.29.0" + } + }, + "@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "dev": true, + "requires": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + } + } + } + }, + "@opentelemetry/sdk-trace-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.1.0.tgz", + "integrity": "sha512-SvVlBFc/jI96u/mmlKm86n9BbTCbQ35nsPoOohqJX6DXH92K0kTe73zGY5r8xoI1QkjR9PizszVJLzMC966y9Q==", + "dev": true, + "requires": { + "@opentelemetry/context-async-hooks": "2.1.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0" + }, + "dependencies": { + "@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "dev": true, + "requires": { + "@opentelemetry/semantic-conventions": "^1.29.0" + } + } + } + }, "@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "dev": true + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz", + "integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==" }, "@oxc-resolver/binding-linux-x64-gnu": { "version": "5.0.0", diff --git a/package.json b/package.json index ec1aa8777..b577f1cd6 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "@flipt-io/flipt-client-js": "^0.2.0", "@growthbook/growthbook": "^1.3.1", "@grpc/grpc-js": "^1.9.13", - "@opentelemetry/api": "^1.3.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.205.0", + "@opentelemetry/semantic-conventions": "^1.37.0", "@protobuf-ts/grpc-transport": "^2.9.0", "@protobuf-ts/runtime-rpc": "^2.9.0", "@swc/helpers": "0.5.17", @@ -60,7 +62,11 @@ "@openfeature/core": "^1.9.1", "@openfeature/server-sdk": "^1.19.0", "@openfeature/web-sdk": "^1.6.2", - "@opentelemetry/sdk-metrics": "^1.15.0", + "@opentelemetry/context-async-hooks": "^2.1.0", + "@opentelemetry/sdk-metrics": "^1.30.1", + "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/sdk-trace-node": "^2.1.0", + "@opentelemetry/sdk-logs": "^0.205.0", "@swc-node/register": "~1.10.0", "@swc/cli": "~0.7.0", "@swc/core": "1.12.9", diff --git a/release-please-config.json b/release-please-config.json index 571ef890f..0e7872d10 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -7,7 +7,8 @@ "prerelease": false, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, - "versioning": "default" + "versioning": "default", + "extra-files": ["src/lib/traces/tracing-hooks.ts"] }, "libs/providers/go-feature-flag": { "release-type": "node", From ef6e3ee92465f3d41cc7ce046c5652fe2c9696cc Mon Sep 17 00:00:00 2001 From: Lukas Reining Date: Thu, 9 Oct 2025 16:47:42 +0200 Subject: [PATCH 2/7] feat: implement new tracing hooks Signed-off-by: Lukas Reining 11 Signed-off-by: Lukas Reining --- libs/hooks/open-telemetry/README.md | 8 ++++--- .../hooks/open-telemetry/src/lib/otel-hook.ts | 23 ++++++++----------- .../src/lib/traces/tracing-hook.spec.ts | 22 ++++++++++++++---- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/libs/hooks/open-telemetry/README.md b/libs/hooks/open-telemetry/README.md index 5cc59b0ed..0f86f6269 100644 --- a/libs/hooks/open-telemetry/README.md +++ b/libs/hooks/open-telemetry/README.md @@ -109,13 +109,15 @@ All hooks support the following options via `OpenTelemetryHookOptions`: #### Custom Attributes -Custom attributes can be extracted from [flag metadata](https://openfeature.dev/specification/types#flag-metadata) by supplying an `attributeMapper`: +Custom attributes can be extracted from hook metadata or evaluation details by supplying an `attributeMapper`: ```typescript +import { HookContext, EvaluationDetails, FlagValue } from '@openfeature/core'; import { EventHook } from '@openfeature/open-telemetry-hooks'; -const attributeMapper = (flagMetadata) => ({ - myCustomAttribute: flagMetadata.someFlagMetadataField, +const attributeMapper = (hookContext: HookContext, evaluationDetails: EvaluationDetails) => ({ + myCustomAttributeFromContext: hookContext.context.targetingKey, + myCustomAttributeFromMetadata: evaluationDetails.flagMetadata.someFlagMetadataField, }); const eventHook = new EventHook({ attributeMapper }); diff --git a/libs/hooks/open-telemetry/src/lib/otel-hook.ts b/libs/hooks/open-telemetry/src/lib/otel-hook.ts index 018bc273c..1d324bafb 100644 --- a/libs/hooks/open-telemetry/src/lib/otel-hook.ts +++ b/libs/hooks/open-telemetry/src/lib/otel-hook.ts @@ -1,24 +1,21 @@ -import type { - EvaluationDetails, - FlagMetadata, - FlagValue, - HookContext, - Logger, - TelemetryAttribute, -} from '@openfeature/core'; +import type { EvaluationDetails, FlagValue, HookContext, Logger, TelemetryAttribute } from '@openfeature/core'; import { createEvaluationEvent } from '@openfeature/core'; import type { Attributes } from '@opentelemetry/api'; type EvaluationEvent = { name: string; attributes: Attributes }; type TelemetryAttributesNames = [keyof typeof TelemetryAttribute][number] | string; -export type AttributeMapper = (flagMetadata: FlagMetadata) => Attributes; +export type AttributeMapper = ( + hookContext: HookContext, + evaluationDetails?: EvaluationDetails, +) => Attributes; export type EventMutator = (event: EvaluationEvent) => EvaluationEvent; export type OpenTelemetryHookOptions = { /** - * A function that maps OpenFeature flag metadata values to OpenTelemetry attributes. + * A function that allows mapping OpenFeature hook context values and + * evaluation details to OpenTelemetry attributes. * This can be used to add custom attributes to the telemetry event. * Note: This function is applied after the excludeAttributes option. */ @@ -59,9 +56,9 @@ export abstract class OpenTelemetryHook { protected safeEventMutator: EventMutator; protected constructor(options?: OpenTelemetryHookOptions, logger?: Logger) { - this.safeAttributeMapper = (flagMetadata: FlagMetadata) => { + this.safeAttributeMapper = (hookContext: HookContext, evaluationDetails?: EvaluationDetails) => { try { - return options?.attributeMapper?.(flagMetadata) || {}; + return options?.attributeMapper?.(hookContext, evaluationDetails) || {}; } catch (err) { logger?.debug(`${this.name}: error in attributeMapper, ${err.message}, ${err.stack}`); return {}; @@ -84,7 +81,7 @@ export abstract class OpenTelemetryHook { evaluationDetails: EvaluationDetails, ): EvaluationEvent { const { name, attributes } = createEvaluationEvent(hookContext, evaluationDetails); - const customAttributes = this.safeAttributeMapper(evaluationDetails.flagMetadata); + const customAttributes = this.safeAttributeMapper(hookContext, evaluationDetails); for (const attributeToExclude of this.attributesToExclude) { delete attributes[attributeToExclude]; diff --git a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts index b73d5b784..0f0df3526 100644 --- a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts +++ b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts @@ -15,6 +15,7 @@ import { ATTR_EXCEPTION_STACKTRACE, ATTR_EXCEPTION_TYPE, } from '@opentelemetry/semantic-conventions'; +import { AttributeMapper } from '../otel-hook'; describe('OpenTelemetry Hooks', () => { let tracerProvider: NodeTracerProvider; @@ -49,7 +50,7 @@ describe('OpenTelemetry Hooks', () => { flagKey: 'flag', flagValueType: 'boolean', defaultValue: true, - context: {}, + context: { targetingKey: 'user_id' }, logger: console, hookData: new MapHookData(), }; @@ -132,13 +133,17 @@ describe('OpenTelemetry Hooks', () => { it('should add custom attribute via attributeMapper', () => { const hook: BaseHook = new EventHook({ - attributeMapper: (meta) => ({ custom: meta.foo }), + attributeMapper: (context, evalDetails) => ({ + key: context.context?.targetingKey, + custom: evalDetails?.flagMetadata.foo, + }), }); hook.before?.(hookContext); hook.after?.(hookContext, details); hook.finally?.(hookContext, details); const finished = memoryLogExporter.getFinishedLogRecords(); expect(finished[0]?.attributes?.custom).toBe('bar'); + expect(finished[0]?.attributes?.key).toBe('user_id'); }); it('should exclude attribute via excludeAttributes', () => { @@ -245,7 +250,10 @@ describe('OpenTelemetry Hooks', () => { it('should add custom attribute via attributeMapper', () => { const hook: BaseHook = new SpanEventHook({ - attributeMapper: (meta) => ({ custom: meta.foo }), + attributeMapper: (context, evalDetails) => ({ + key: context.context?.targetingKey, + custom: evalDetails?.flagMetadata.foo, + }), }); const span = tracer.startSpan('test-span'); context.with(trace.setSpan(context.active(), span), () => { @@ -257,6 +265,7 @@ describe('OpenTelemetry Hooks', () => { const finished = memorySpanExporter.getFinishedSpans(); const attrs = finished[0].events[0].attributes; expect(attrs?.custom).toBe('bar'); + expect(attrs?.key).toBe('user_id'); }); it('should exclude attribute via excludeAttributes', () => { @@ -386,8 +395,12 @@ describe('OpenTelemetry Hooks', () => { it('should add custom attribute via attributeMapper', () => { const hook: BaseHook = new SpanHook({ - attributeMapper: (meta) => ({ custom: meta.foo }), + attributeMapper: (context, evalDetails) => ({ + key: context.context?.targetingKey, + custom: evalDetails?.flagMetadata.foo, + }), }); + const span = tracer.startSpan('test-span'); context.with(trace.setSpan(context.active(), span), () => { hook.before?.(hookContext); @@ -398,6 +411,7 @@ describe('OpenTelemetry Hooks', () => { const finished = memorySpanExporter.getFinishedSpans(); const evalSpan = finished[0]; expect(evalSpan.attributes?.custom).toBe('bar'); + expect(evalSpan.attributes?.custom).toBe('user_id'); }); it('should exclude attribute via excludeAttributes', () => { From 31574b3e041979f4b433c1f84172dbad657f2bf1 Mon Sep 17 00:00:00 2001 From: Lukas Reining Date: Thu, 9 Oct 2025 16:48:28 +0200 Subject: [PATCH 3/7] chore: clean Signed-off-by: Lukas Reining 11 Signed-off-by: Lukas Reining --- libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts index 0f0df3526..59dbbef62 100644 --- a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts +++ b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts @@ -15,7 +15,6 @@ import { ATTR_EXCEPTION_STACKTRACE, ATTR_EXCEPTION_TYPE, } from '@opentelemetry/semantic-conventions'; -import { AttributeMapper } from '../otel-hook'; describe('OpenTelemetry Hooks', () => { let tracerProvider: NodeTracerProvider; From ae49b81aa3d7b9b1a1251f10918cd900b3ff9670 Mon Sep 17 00:00:00 2001 From: Lukas Reining Date: Thu, 9 Oct 2025 16:50:18 +0200 Subject: [PATCH 4/7] chore: clean Signed-off-by: Lukas Reining 11 Signed-off-by: Lukas Reining --- libs/hooks/open-telemetry/src/lib/otel-hook.ts | 7 ++----- .../open-telemetry/src/lib/traces/tracing-hook.spec.ts | 6 +++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/libs/hooks/open-telemetry/src/lib/otel-hook.ts b/libs/hooks/open-telemetry/src/lib/otel-hook.ts index 1d324bafb..609627b97 100644 --- a/libs/hooks/open-telemetry/src/lib/otel-hook.ts +++ b/libs/hooks/open-telemetry/src/lib/otel-hook.ts @@ -5,10 +5,7 @@ import type { Attributes } from '@opentelemetry/api'; type EvaluationEvent = { name: string; attributes: Attributes }; type TelemetryAttributesNames = [keyof typeof TelemetryAttribute][number] | string; -export type AttributeMapper = ( - hookContext: HookContext, - evaluationDetails?: EvaluationDetails, -) => Attributes; +export type AttributeMapper = (hookContext: HookContext, evaluationDetails: EvaluationDetails) => Attributes; export type EventMutator = (event: EvaluationEvent) => EvaluationEvent; @@ -56,7 +53,7 @@ export abstract class OpenTelemetryHook { protected safeEventMutator: EventMutator; protected constructor(options?: OpenTelemetryHookOptions, logger?: Logger) { - this.safeAttributeMapper = (hookContext: HookContext, evaluationDetails?: EvaluationDetails) => { + this.safeAttributeMapper = (hookContext: HookContext, evaluationDetails: EvaluationDetails) => { try { return options?.attributeMapper?.(hookContext, evaluationDetails) || {}; } catch (err) { diff --git a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts index 59dbbef62..e13ccfa25 100644 --- a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts +++ b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts @@ -134,7 +134,7 @@ describe('OpenTelemetry Hooks', () => { const hook: BaseHook = new EventHook({ attributeMapper: (context, evalDetails) => ({ key: context.context?.targetingKey, - custom: evalDetails?.flagMetadata.foo, + custom: evalDetails.flagMetadata.foo, }), }); hook.before?.(hookContext); @@ -251,7 +251,7 @@ describe('OpenTelemetry Hooks', () => { const hook: BaseHook = new SpanEventHook({ attributeMapper: (context, evalDetails) => ({ key: context.context?.targetingKey, - custom: evalDetails?.flagMetadata.foo, + custom: evalDetails.flagMetadata.foo, }), }); const span = tracer.startSpan('test-span'); @@ -396,7 +396,7 @@ describe('OpenTelemetry Hooks', () => { const hook: BaseHook = new SpanHook({ attributeMapper: (context, evalDetails) => ({ key: context.context?.targetingKey, - custom: evalDetails?.flagMetadata.foo, + custom: evalDetails.flagMetadata.foo, }), }); From 5462fa49a22ac500786b0fe19d9ba19a3e5db70c Mon Sep 17 00:00:00 2001 From: Lukas Reining Date: Thu, 9 Oct 2025 18:24:07 +0200 Subject: [PATCH 5/7] fix: tests Signed-off-by: Lukas Reining 11 Signed-off-by: Lukas Reining --- libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.ts | 2 +- libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.ts b/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.ts index 87187cd8c..c8727ad45 100644 --- a/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.ts +++ b/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.ts @@ -82,7 +82,7 @@ export class MetricsHook extends OpenTelemetryHook implements BaseHook { [TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name, [TelemetryAttribute.VARIANT]: evaluationDetails.variant ?? evaluationDetails.value?.toString(), [TelemetryAttribute.REASON]: evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN, - ...this.safeAttributeMapper(evaluationDetails?.flagMetadata || {}), + ...this.safeAttributeMapper(hookContext, evaluationDetails), }); } diff --git a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts index e13ccfa25..774312ea6 100644 --- a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts +++ b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts @@ -410,7 +410,7 @@ describe('OpenTelemetry Hooks', () => { const finished = memorySpanExporter.getFinishedSpans(); const evalSpan = finished[0]; expect(evalSpan.attributes?.custom).toBe('bar'); - expect(evalSpan.attributes?.custom).toBe('user_id'); + expect(evalSpan.attributes?.key).toBe('user_id'); }); it('should exclude attribute via excludeAttributes', () => { From 24dd078aaabeb4ea91f84f88f15383486abbc5cb Mon Sep 17 00:00:00 2001 From: Lukas Reining Date: Thu, 9 Oct 2025 18:36:52 +0200 Subject: [PATCH 6/7] fix: tests Signed-off-by: Lukas Reining 11 Signed-off-by: Lukas Reining --- libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.spec.ts b/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.spec.ts index fffc8c9d5..00c316c94 100644 --- a/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.spec.ts +++ b/libs/hooks/open-telemetry/src/lib/metrics/metrics-hook.spec.ts @@ -170,7 +170,7 @@ describe(MetricsHook.name, () => { } as EvaluationDetails; // configure a mapper for our custom properties - const attributeMapper: AttributeMapper = (flagMetadata) => { + const attributeMapper: AttributeMapper = (_, { flagMetadata }) => { return { [CUSTOM_ATTR_KEY_1]: flagMetadata[CUSTOM_ATTR_KEY_1], [CUSTOM_ATTR_KEY_2]: flagMetadata[CUSTOM_ATTR_KEY_2], From 580dafd7cb89fc9c7521e85b4afdc996a008533d Mon Sep 17 00:00:00 2001 From: Lukas Reining Date: Thu, 9 Oct 2025 20:44:10 +0200 Subject: [PATCH 7/7] chore: move internal types to extra file Signed-off-by: Lukas Reining 11 Signed-off-by: Lukas Reining --- libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts | 2 +- .../open-telemetry/src/lib/traces/tracing-hooks-internal.ts | 4 ++++ libs/hooks/open-telemetry/src/lib/traces/tracing-hooks.ts | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 libs/hooks/open-telemetry/src/lib/traces/tracing-hooks-internal.ts diff --git a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts index 774312ea6..408b9f03d 100644 --- a/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts +++ b/libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts @@ -4,7 +4,7 @@ import { NodeTracerProvider, SimpleSpanProcessor, InMemorySpanExporter } from '@ import { logs } from '@opentelemetry/api-logs'; import { LoggerProvider, SimpleLogRecordProcessor, InMemoryLogRecordExporter } from '@opentelemetry/sdk-logs'; import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import type { SpanAttributesTracingHookData } from './tracing-hooks'; +import type { SpanAttributesTracingHookData } from './tracing-hooks-internal'; import { EventHook } from './tracing-hooks'; import { SpanEventHook, SpanHook } from './tracing-hooks'; import type { BaseHook, EvaluationDetails, FlagValue, HookContext } from '@openfeature/core'; diff --git a/libs/hooks/open-telemetry/src/lib/traces/tracing-hooks-internal.ts b/libs/hooks/open-telemetry/src/lib/traces/tracing-hooks-internal.ts new file mode 100644 index 000000000..48de2cb02 --- /dev/null +++ b/libs/hooks/open-telemetry/src/lib/traces/tracing-hooks-internal.ts @@ -0,0 +1,4 @@ +import type { Span } from '@opentelemetry/api'; + +export const HookContextSpanKey = Symbol('evaluation_span'); +export type SpanAttributesTracingHookData = { [HookContextSpanKey]: Span }; diff --git a/libs/hooks/open-telemetry/src/lib/traces/tracing-hooks.ts b/libs/hooks/open-telemetry/src/lib/traces/tracing-hooks.ts index ca4819380..62852ef1f 100644 --- a/libs/hooks/open-telemetry/src/lib/traces/tracing-hooks.ts +++ b/libs/hooks/open-telemetry/src/lib/traces/tracing-hooks.ts @@ -10,6 +10,8 @@ import { } from '@opentelemetry/semantic-conventions'; import type { OpenTelemetryHookOptions } from '../otel-hook'; import { OpenTelemetryHook } from '../otel-hook'; +import type { SpanAttributesTracingHookData } from './tracing-hooks-internal'; +import { HookContextSpanKey } from './tracing-hooks-internal'; const LIBRARY_NAME = '@openfeature/open-telemetry-hooks'; const LIBRARY_VERSION = '0.4.0'; //x-release-please-version @@ -103,8 +105,6 @@ export class SpanEventHook extends OpenTelemetryHook implements BaseHook { } const tracer = trace.getTracer(LIBRARY_NAME, LIBRARY_VERSION); -const HookContextSpanKey = Symbol('evaluation_span'); -export type SpanAttributesTracingHookData = { [HookContextSpanKey]: Span }; /** * A hook that creates a new span for each flag evaluation and sets the evaluation