From 76b8578083c0fd95bbadba2a3ca133524a15e456 Mon Sep 17 00:00:00 2001 From: Marco Schaefer <47627413+codecapitano@users.noreply.github.com> Date: Thu, 20 Feb 2025 15:26:58 +0100 Subject: [PATCH] chore(web-tracing): attach user attributes to spans (#990) --- CHANGELOG.md | 5 ++ packages/core/src/metas/types.ts | 24 +++++++ packages/web-sdk/src/index.ts | 1 + .../faroMetaAttributesSpanProcessor.test.ts | 50 ++++++++++++++ .../src/faroMetaAttributesSpanProcessor.ts | 66 +++++++++++++++++++ packages/web-tracing/src/instrumentation.ts | 23 ++++--- .../web-tracing/src/sessionSpanProcessor.ts | 4 ++ 7 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 packages/web-tracing/src/faroMetaAttributesSpanProcessor.test.ts create mode 100644 packages/web-tracing/src/faroMetaAttributesSpanProcessor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 642c63261..2707f35a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Next +- feat (`@grafana/faro-web-sdk`): Enhance user meta properties to align with OTEL semantic + conventions for user attributes (#990) + +- Chore (`@grafana/faro-web-tracing`): Add user attributes to spans (#990) + ## 1.13.3 - Chore (`@grafana/faro-web-sdk`): Ensure all properties in `attributes` and `context` objects are diff --git a/packages/core/src/metas/types.ts b/packages/core/src/metas/types.ts index db3ac6866..cd287ebf9 100644 --- a/packages/core/src/metas/types.ts +++ b/packages/core/src/metas/types.ts @@ -35,9 +35,33 @@ export interface MetaApp { } export interface MetaUser { + /** + * User email address. + */ email?: string; + /** + * Unique identifier + */ id?: string; + /** + * Short name or login/username of the user. + */ username?: string; + /** + * User’s full name + */ + fullName?: string; + /** + * comma separated list of user roles. "admin",editor" etc. + */ + roles?: string; + /** + * Unique user hash to correlate information for a user in anonymized form. + */ + hash?: string; + /** + * arbitrary user attributes, must be of type string. + */ attributes?: MetaAttributes; } diff --git a/packages/web-sdk/src/index.ts b/packages/web-sdk/src/index.ts index 7eb5ace1a..cb5d23ab1 100644 --- a/packages/web-sdk/src/index.ts +++ b/packages/web-sdk/src/index.ts @@ -78,6 +78,7 @@ export { isToString, isTypeof, isUndefined, + isEmpty, InternalLoggerLevel, LogLevel, noop, diff --git a/packages/web-tracing/src/faroMetaAttributesSpanProcessor.test.ts b/packages/web-tracing/src/faroMetaAttributesSpanProcessor.test.ts new file mode 100644 index 000000000..e578c0968 --- /dev/null +++ b/packages/web-tracing/src/faroMetaAttributesSpanProcessor.test.ts @@ -0,0 +1,50 @@ +import { FaroMetaAttributesSpanProcessor } from './faroMetaAttributesSpanProcessor'; + +describe('faroMetaAttributesSpanProcessor', () => { + const processor = new FaroMetaAttributesSpanProcessor( + { + onStart: jest.fn(), + onEnd: jest.fn(), + shutdown: jest.fn(), + forceFlush: jest.fn(), + }, + { + value: { + session: { + id: 'session-id', + }, + user: { + email: 'email', + id: 'id', + username: 'user-short-name', + fullName: 'user-full-name', + roles: 'admin, editor,viewer', + hash: 'hash', + }, + }, + add: jest.fn(), + remove: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + } + ); + + it('adds attributes to span', () => { + const span = { + attributes: {}, + }; + + processor.onStart(span as any, {} as any); + + expect(span.attributes).toStrictEqual({ + 'session.id': 'session-id', + session_id: 'session-id', + 'user.email': 'email', + 'user.id': 'id', + 'user.full_name': 'user-full-name', + 'user.name': 'user-short-name', + 'user.roles': ['admin', 'editor', 'viewer'], + 'user.hash': 'hash', + }); + }); +}); diff --git a/packages/web-tracing/src/faroMetaAttributesSpanProcessor.ts b/packages/web-tracing/src/faroMetaAttributesSpanProcessor.ts new file mode 100644 index 000000000..62f9ba908 --- /dev/null +++ b/packages/web-tracing/src/faroMetaAttributesSpanProcessor.ts @@ -0,0 +1,66 @@ +import type { Context } from '@opentelemetry/api'; +import type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-web'; +// False positive. Package can be resolved. +// eslint-disable-next-line import/no-unresolved +import { ATTR_SESSION_ID } from '@opentelemetry/semantic-conventions/incubating'; + +import type { Metas } from '@grafana/faro-web-sdk'; + +export class FaroMetaAttributesSpanProcessor implements SpanProcessor { + constructor( + private processor: SpanProcessor, + private metas: Metas + ) {} + + forceFlush(): Promise { + return this.processor.forceFlush(); + } + + onStart(span: Span, parentContext: Context): void { + const session = this.metas.value.session; + + if (session?.id) { + span.attributes[ATTR_SESSION_ID] = session.id; + /** + * @deprecated will be removed in the future and has been replaced by ATTR_SESSION_ID (session.id) + */ + span.attributes['session_id'] = session.id; + } + + const user = this.metas.value.user ?? {}; + + if (user.email) { + span.attributes['user.email'] = user.email; + } + + if (user.id) { + span.attributes['user.id'] = user.id; + } + + if (user.username) { + span.attributes['user.name'] = user.username; + } + + if (user.fullName) { + span.attributes['user.full_name'] = user.fullName; + } + + if (user.roles) { + span.attributes['user.roles'] = user.roles.split(',').map((role) => role.trim()); + } + + if (user.hash) { + span.attributes['user.hash'] = user.hash; + } + + this.processor.onStart(span, parentContext); + } + + onEnd(span: ReadableSpan): void { + this.processor.onEnd(span); + } + + shutdown(): Promise { + return this.processor.shutdown(); + } +} diff --git a/packages/web-tracing/src/instrumentation.ts b/packages/web-tracing/src/instrumentation.ts index 40031d55f..e7e1274bd 100644 --- a/packages/web-tracing/src/instrumentation.ts +++ b/packages/web-tracing/src/instrumentation.ts @@ -18,10 +18,10 @@ import { import { BaseInstrumentation, Transport, VERSION } from '@grafana/faro-web-sdk'; +import { FaroMetaAttributesSpanProcessor } from './faroMetaAttributesSpanProcessor'; import { FaroTraceExporter } from './faroTraceExporter'; import { getDefaultOTELInstrumentations } from './getDefaultOTELInstrumentations'; import { getSamplingDecision } from './sampler'; -import { FaroSessionSpanProcessor } from './sessionSpanProcessor'; import type { TracingInstrumentationOptions } from './types'; // the providing of app name here is not great @@ -75,19 +75,18 @@ export class TracingInstrumentation extends BaseInstrumentation { }; }, }, + spanProcessors: [ + options.spanProcessor ?? + new FaroMetaAttributesSpanProcessor( + new BatchSpanProcessor(new FaroTraceExporter({ api: this.api }), { + scheduledDelayMillis: TracingInstrumentation.SCHEDULED_BATCH_DELAY_MS, + maxExportBatchSize: 30, + }), + this.metas + ), + ], }); - provider.addSpanProcessor( - options.spanProcessor ?? - new FaroSessionSpanProcessor( - new BatchSpanProcessor(new FaroTraceExporter({ api: this.api }), { - scheduledDelayMillis: TracingInstrumentation.SCHEDULED_BATCH_DELAY_MS, - maxExportBatchSize: 30, - }), - this.metas - ) - ); - provider.register({ propagator: options.propagator ?? new W3CTraceContextPropagator(), contextManager: options.contextManager ?? new ZoneContextManager(), diff --git a/packages/web-tracing/src/sessionSpanProcessor.ts b/packages/web-tracing/src/sessionSpanProcessor.ts index 94ca10864..b5380cdc9 100644 --- a/packages/web-tracing/src/sessionSpanProcessor.ts +++ b/packages/web-tracing/src/sessionSpanProcessor.ts @@ -6,6 +6,10 @@ import { ATTR_SESSION_ID } from '@opentelemetry/semantic-conventions/incubating' import type { Metas } from '@grafana/faro-web-sdk'; +/** + * @deprecated + * please use FaroMetaAttributesSpanProcessor instead + */ export class FaroSessionSpanProcessor implements SpanProcessor { constructor( private processor: SpanProcessor,