diff --git a/.size-limit.cjs b/.size-limit.cjs index 99fdda21..80dfc255 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -30,7 +30,7 @@ module.exports = [ { name: 'artifacts/splunk-otel-web.js', - limit: '76 kB', + limit: '77 kB', path: './packages/web/dist/artifacts/splunk-otel-web-legacy.js', }, diff --git a/packages/session-recorder/src/index.ts b/packages/session-recorder/src/index.ts index 9a046169..3ca60c2c 100644 --- a/packages/session-recorder/src/index.ts +++ b/packages/session-recorder/src/index.ts @@ -196,10 +196,16 @@ const SplunkRumRecorder = { debug, headers, getResourceAttributes() { - return { + const newAttributes = { ...resource.attributes, 'splunk.rumSessionId': SplunkRum.getSessionId(), } + const anonymousId = SplunkRum.getAnonymousId() + if (anonymousId) { + newAttributes['user.anonymousId'] = anonymousId + } + + return newAttributes }, }) const processor = new BatchLogProcessor(exporter, {}) diff --git a/packages/web/src/SplunkSpanAttributesProcessor.ts b/packages/web/src/SplunkSpanAttributesProcessor.ts index b16f30ba..e2ee46b3 100644 --- a/packages/web/src/SplunkSpanAttributesProcessor.ts +++ b/packages/web/src/SplunkSpanAttributesProcessor.ts @@ -18,12 +18,18 @@ import { Attributes } from '@opentelemetry/api' import { Span, SpanProcessor } from '@opentelemetry/sdk-trace-base' +import { forgetAnonymousId, getOrCreateAnonymousId } from './user-tracking' +import { SplunkOtelWebConfig } from './types/config' import { updateSessionStatus } from './session' export class SplunkSpanAttributesProcessor implements SpanProcessor { private readonly _globalAttributes: Attributes - constructor(globalAttributes: Attributes) { + constructor( + globalAttributes: Attributes, + private useLocalStorageForSessionMetadata: boolean, + private getUserTracking: () => SplunkOtelWebConfig['user']['trackingMode'], + ) { this._globalAttributes = globalAttributes ?? {} } @@ -46,6 +52,14 @@ export class SplunkSpanAttributesProcessor implements SpanProcessor { span.setAttribute('location.href', location.href) span.setAttributes(this._globalAttributes) span.setAttribute('splunk.rumSessionId', sessionState.id) + + if (this.getUserTracking() === 'anonymousTracking') { + span.setAttribute( + 'user.anonymousId', + getOrCreateAnonymousId({ useLocalStorage: this.useLocalStorageForSessionMetadata }), + ) + } + span.setAttribute('browser.instance.visibility_state', document.visibilityState) } @@ -60,6 +74,7 @@ export class SplunkSpanAttributesProcessor implements SpanProcessor { } shutdown(): Promise { + forgetAnonymousId() return Promise.resolve() } } diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 52e4959f..8978a772 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -65,6 +65,7 @@ import { SplunkOTLPTraceExporter } from './exporters/otlp' import { registerGlobal, unregisterGlobal } from './global-utils' import { BrowserInstanceService } from './services/BrowserInstanceService' import { SessionId } from './session' +import { forgetAnonymousId, getOrCreateAnonymousId } from './user-tracking' import { isPersistenceType, SplunkOtelWebConfig, @@ -227,6 +228,8 @@ export interface SplunkOtelWebType extends SplunkOtelWebEventTarget { error: (...args: Array) => void + getAnonymousId: () => string | undefined + /** * This method provides access to computed, final value of global attributes, which are applied to all created spans. */ @@ -246,9 +249,12 @@ export interface SplunkOtelWebType extends SplunkOtelWebEventTarget { readonly resource?: Resource setGlobalAttributes: (attributes: Attributes) => void + + setUserTrackingMode: (mode: SplunkOtelWebConfig['user']['trackingMode']) => void } let inited = false +let userTrackingMode: SplunkOtelWebConfig['user']['trackingMode'] = 'noTracking' let _deregisterInstrumentations: () => void | undefined let _deinitSessionTracking: () => void | undefined let _errorInstrumentation: SplunkErrorInstrumentation | undefined @@ -278,6 +284,7 @@ export const SplunkRum: SplunkOtelWebType = { }, init: function (options) { + userTrackingMode = options.user?.trackingMode ?? 'noTracking' // "env" based config still a bad idea for web if (!('OTEL_TRACES_EXPORTER' in _globalThis)) { _globalThis.OTEL_TRACES_EXPORTER = 'none' @@ -426,13 +433,17 @@ export const SplunkRum: SplunkOtelWebType = { return null }).filter((a): a is Exclude => Boolean(a)) - this.attributesProcessor = new SplunkSpanAttributesProcessor({ - ...(deploymentEnvironment - ? { 'environment': deploymentEnvironment, 'deployment.environment': deploymentEnvironment } - : {}), - ...(version ? { 'app.version': version } : {}), - ...(processedOptions.globalAttributes || {}), - }) + this.attributesProcessor = new SplunkSpanAttributesProcessor( + { + ...(deploymentEnvironment + ? { 'environment': deploymentEnvironment, 'deployment.environment': deploymentEnvironment } + : {}), + ...(version ? { 'app.version': version } : {}), + ...(processedOptions.globalAttributes || {}), + }, + this._processedOptions.persistence === 'localStorage', + () => userTrackingMode, + ) provider.addSpanProcessor(this.attributesProcessor) if (processedOptions.beaconEndpoint) { @@ -501,6 +512,7 @@ export const SplunkRum: SplunkOtelWebType = { diag.disable() unregisterGlobal() + forgetAnonymousId() inited = false }, @@ -549,6 +561,16 @@ export const SplunkRum: SplunkOtelWebType = { return this.removeEventListener(name, callback) }, + setUserTrackingMode(mode: SplunkOtelWebConfig['user']['trackingMode']) { + userTrackingMode = mode + }, + + getAnonymousId() { + if (userTrackingMode === 'anonymousTracking') { + return getOrCreateAnonymousId({ useLocalStorage: this._processedOptions.persistence === 'localStorage' }) + } + }, + getSessionId() { if (!inited) { return undefined diff --git a/packages/web/src/types/config.ts b/packages/web/src/types/config.ts index e85d4ccb..e3c58f16 100644 --- a/packages/web/src/types/config.ts +++ b/packages/web/src/types/config.ts @@ -177,6 +177,11 @@ export interface SplunkOtelWebConfig { */ tracer?: WebTracerConfig + user?: { + /** Sets tracking mode of user. Defaults to 'noTracking'. */ + trackingMode?: 'noTracking' | 'anonymousTracking' + } + /** * Sets a value for the 'app.version' attribute */ diff --git a/packages/web/src/user-tracking/index.ts b/packages/web/src/user-tracking/index.ts new file mode 100644 index 00000000..c775d1f6 --- /dev/null +++ b/packages/web/src/user-tracking/index.ts @@ -0,0 +1,69 @@ +/** + * + * Copyright 2020-2025 Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { safelyGetLocalStorage } from '../storage/local-store' +import { generateId } from '../utils' + +const KEY = 'splunk.anonymousId' +let anonymousId: string | undefined + +export const getOrCreateAnonymousId = ({ useLocalStorage }: { useLocalStorage: boolean }) => { + if (anonymousId) { + return anonymousId + } + + const id = useLocalStorage ? getAnonymousIdFromLocalStorage() : getAnonymousIdFromCookie() + anonymousId = id + return id +} + +export const forgetAnonymousId = () => { + anonymousId = undefined +} + +const getAnonymousIdFromLocalStorage = () => { + const lsValue = safelyGetLocalStorage(KEY) + if (lsValue) { + return lsValue + } + + const newId = generateAnonymousId() + localStorage.setItem(KEY, newId) + return newId +} + +const getAnonymousIdFromCookie = () => { + const cookieValue = document.cookie + .split('; ') + .find((row) => row.startsWith(KEY + '=')) + ?.split('=')[1] + + if (cookieValue) { + setCookie(cookieValue) + return cookieValue + } + + const newId = generateAnonymousId() + setCookie(newId) + return newId +} + +const generateAnonymousId = () => generateId(128) + +const setCookie = (newId: string) => { + document.cookie = `${KEY}=${newId}; max-age=${60 * 60 * 24 * 400}` +} diff --git a/packages/web/tests/SplunkSpanAttributesProcessor.test.ts b/packages/web/tests/SplunkSpanAttributesProcessor.test.ts index cdd78320..25233a6e 100644 --- a/packages/web/tests/SplunkSpanAttributesProcessor.test.ts +++ b/packages/web/tests/SplunkSpanAttributesProcessor.test.ts @@ -27,6 +27,7 @@ describe('SplunkSpanAttributesProcessor', () => { key1: 'value1', }, false, + () => false, ) expect(processor.getGlobalAttributes()).toStrictEqual({ @@ -41,6 +42,7 @@ describe('SplunkSpanAttributesProcessor', () => { key2: 'value2', }, false, + () => false, ) processor.setGlobalAttributes({ diff --git a/packages/web/tests/user-tracking.test.ts b/packages/web/tests/user-tracking.test.ts new file mode 100644 index 00000000..d96244ae --- /dev/null +++ b/packages/web/tests/user-tracking.test.ts @@ -0,0 +1,102 @@ +/** + * + * Copyright 2020-2025 Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import SplunkRum from '../src/index' +import * as tracing from '@opentelemetry/sdk-trace-base' +import { describe, it, expect, afterEach } from 'vitest' +import { deinit, initWithDefaultConfig, SpanCapturer } from './utils' + +const createSpan = (tracer: tracing.Tracer) => { + const span = tracer.startSpan('testSpan') + span.end() + return span as tracing.Span +} + +const getLocalStorage = () => localStorage.getItem('splunk.anonymousId') + +const getCookie = () => + document.cookie + .split('; ') + .find((row) => row.startsWith('splunk.anonymousId=')) + ?.split('=')[1] + +describe('userTracking is reflected', () => { + const capturer = new SpanCapturer() + + afterEach(() => { + deinit(true) + }) + + it('cookies/userTrackingMode is default, then anonymousTracking', () => { + initWithDefaultConfig(capturer) + + const tracer = SplunkRum.provider.getTracer('test') + const spanWithoutAnonymousId = createSpan(tracer) + expect(spanWithoutAnonymousId.attributes['user.anonymousId'], 'Checking user.anonymousId').toBeUndefined() + + SplunkRum.setUserTrackingMode('anonymousTracking') + + const spanWithAnonymousId = createSpan(tracer) + const anonymousId = spanWithAnonymousId.attributes['user.anonymousId'] + expect(anonymousId, 'Checking user.anonymousId').toBeDefined() + expect(getCookie(), 'Checking cookie value').equal(anonymousId) + }) + + it('cookies/userTrackingMode is anonymousTracking, then noTracking', () => { + initWithDefaultConfig(capturer, { user: { trackingMode: 'anonymousTracking' } }) + + const tracer = SplunkRum.provider.getTracer('test') + const spanWithAnonymousId = createSpan(tracer) + const anonymousId = spanWithAnonymousId.attributes['user.anonymousId'] + expect(anonymousId, 'Checking user.anonymousId').toBeDefined() + expect(getCookie(), 'Checking cookie value').equal(anonymousId) + + SplunkRum.setUserTrackingMode('noTracking') + + const spanWithoutAnonymousId = createSpan(tracer) + expect(spanWithoutAnonymousId.attributes['user.anonymousId'], 'Checking user.anonymousId').toBeUndefined() + }) + + it('localStorage/userTrackingMode is anonymousTracking, then noTracking', () => { + initWithDefaultConfig(capturer, { user: { trackingMode: 'anonymousTracking' }, persistence: 'localStorage' }) + + const tracer = SplunkRum.provider.getTracer('test') + const spanWithAnonymousId = createSpan(tracer) + const anonymousId = spanWithAnonymousId.attributes['user.anonymousId'] + expect(anonymousId, 'Checking user.anonymousId').toBe(getLocalStorage()) + + SplunkRum.setUserTrackingMode('noTracking') + + const spanWithoutAnonymousId = createSpan(tracer) + expect(spanWithoutAnonymousId.attributes['user.anonymousId'], 'Checking user.anonymousId').toBeUndefined() + }) + + it('localStorage/userTrackingMode is default, then anonymousTracking', () => { + initWithDefaultConfig(capturer, { persistence: 'localStorage' }) + + const tracer = SplunkRum.provider.getTracer('test') + const spanWithoutAnonymousId = createSpan(tracer) + expect(spanWithoutAnonymousId.attributes['user.anonymousId'], 'Checking user.anonymousId').toBeUndefined() + + SplunkRum.setUserTrackingMode('anonymousTracking') + + const spanWithAnonymousId = createSpan(tracer) + const anonymousId = spanWithAnonymousId.attributes['user.anonymousId'] + expect(anonymousId, 'Checking user.anonymousId').toBe(getLocalStorage()) + }) +})