diff --git a/libs/hooks/open-telemetry/README.md b/libs/hooks/open-telemetry/README.md index 5d741b922..0f86f6269 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,105 @@ 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 hook metadata or evaluation details 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 { HookContext, EvaluationDetails, FlagValue } from '@openfeature/core'; +import { EventHook } from '@openfeature/open-telemetry-hooks'; + +const attributeMapper = (hookContext: HookContext, evaluationDetails: EvaluationDetails) => ({ + myCustomAttributeFromContext: hookContext.context.targetingKey, + myCustomAttributeFromMetadata: evaluationDetails.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 +174,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..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 @@ -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(); }); @@ -178,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], @@ -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..c8727ad45 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, - ...this.safeAttributeMapper(evaluationDetails?.flagMetadata || {}), + [TelemetryAttribute.KEY]: hookContext.flagKey, + [TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name, + [TelemetryAttribute.VARIANT]: evaluationDetails.variant ?? evaluationDetails.value?.toString(), + [TelemetryAttribute.REASON]: evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN, + ...this.safeAttributeMapper(hookContext, evaluationDetails), }); } 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..609627b97 100644 --- a/libs/hooks/open-telemetry/src/lib/otel-hook.ts +++ b/libs/hooks/open-telemetry/src/lib/otel-hook.ts @@ -1,30 +1,92 @@ -import type { FlagMetadata, Logger } from '@openfeature/server-sdk'; +import type { EvaluationDetails, FlagValue, HookContext, Logger, TelemetryAttribute } from '@openfeature/core'; +import { createEvaluationEvent } from '@openfeature/core'; import type { Attributes } from '@opentelemetry/api'; -export type AttributeMapper = (flagMetadata: FlagMetadata) => Attributes; +type EvaluationEvent = { name: string; attributes: Attributes }; +type TelemetryAttributesNames = [keyof typeof TelemetryAttribute][number] | string; + +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. */ 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) { - this.safeAttributeMapper = (flagMetadata: FlagMetadata) => { + protected attributesToExclude: TelemetryAttributesNames[]; + protected excludeExceptions: boolean; + protected safeAttributeMapper: AttributeMapper; + protected safeEventMutator: EventMutator; + + protected constructor(options?: OpenTelemetryHookOptions, logger?: Logger) { + 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 {}; } }; + 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(hookContext, evaluationDetails); + + 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..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 @@ -1,197 +1,465 @@ -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-internal'; +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: { targetingKey: 'user_id' }, + 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: (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'); + }); - 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: (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); + 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'); + expect(attrs?.key).toBe('user_id'); + }); + + 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: (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); + hook.after?.(hookContext, details); + hook.finally?.(hookContext, details); + span.end(); + }); + const finished = memorySpanExporter.getFinishedSpans(); + const evalSpan = finished[0]; + expect(evalSpan.attributes?.custom).toBe('bar'); + expect(evalSpan.attributes?.key).toBe('user_id'); + }); + + 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-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 new file mode 100644 index 000000000..62852ef1f --- /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'; +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 + +/** + * 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); + +/** + * 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",