From 5c4d4e8140b69f096893ed1270fa4dac93511ce7 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 12 Feb 2026 17:56:00 -0500 Subject: [PATCH] Add realm/tenant telemetry context and opt-in debug logging Enrich telemetry events with realm, tenantId, shortCode, and configSources from resolved configuration. Add opt-in trace logging to the Telemetry class gated by SFCC_TELEMETRY_LOG=true for debugging telemetry flow. --- .../b2c-tooling-sdk/src/cli/base-command.ts | 60 ++++++ .../src/telemetry/telemetry.ts | 15 ++ .../test/cli/base-command.test.ts | 199 ++++++++++++++++++ .../test/telemetry/telemetry.test.ts | 71 +++++++ 4 files changed, 345 insertions(+) diff --git a/packages/b2c-tooling-sdk/src/cli/base-command.ts b/packages/b2c-tooling-sdk/src/cli/base-command.ts index 1d525687..5cd259fd 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -7,6 +7,7 @@ import {Command, Flags, type Interfaces} from '@oclif/core'; import {loadConfig} from './config.js'; import type {LoadConfigOptions, PluginSources} from './config.js'; import type {ResolvedB2CConfig} from '../config/index.js'; +import {parseFriendlySandboxId} from '../operations/ods/sandbox-lookup.js'; import type { ConfigSourcesHookOptions, ConfigSourcesHookResult, @@ -167,6 +168,8 @@ export abstract class BaseCommand extends Command { await this.initTelemetryFromConfig(); this.resolvedConfig = this.loadConfiguration(); + + this.addTelemetryContext(); } /** @@ -325,6 +328,63 @@ export abstract class BaseCommand extends Command { return loadConfig({}, this.getBaseConfigOptions(), this.getPluginSources()); } + /** + * Enrich telemetry with realm/tenant context from the resolved configuration. + * Called after loadConfiguration() in init() so that COMMAND_SUCCESS and + * COMMAND_EXCEPTION events include organizational context. + */ + protected addTelemetryContext(): void { + if (!this.telemetry) return; + + try { + const attributes: TelemetryAttributes = {}; + const {values, sources} = this.resolvedConfig; + + // Extract realm from tenantId (e.g., "zzpq_019" or "f_ecom_zzpq_019") + if (values.tenantId) { + attributes.tenantId = values.tenantId; + const parsed = parseFriendlySandboxId(values.tenantId); + if (parsed) { + attributes.realm = parsed.realm; + } + } + + // Fallback: extract realm from hostname (e.g., "zzpq-019.dx.commercecloud.salesforce.com") + if (!attributes.realm && values.hostname) { + const parsed = parseFriendlySandboxId(values.hostname.split('.')[0]); + if (parsed) { + attributes.realm = parsed.realm; + } + } + + if (values.hostname) { + attributes.hostname = values.hostname; + } + + if (values.clientId) { + attributes.clientId = values.clientId; + } + + if (values.shortCode) { + attributes.shortCode = values.shortCode; + } + + // Record which config sources contributed + if (sources.length > 0) { + attributes.configSources = sources.map((s) => s.name).join(', '); + } + + if (Object.keys(attributes).length > 0) { + this.telemetry.addAttributes(attributes); + if (process.env.SFCC_TELEMETRY_LOG === 'true') { + this.logger.debug({attributes}, 'telemetry context enriched'); + } + } + } catch { + // Best-effort: telemetry context enrichment must never prevent command execution + } + } + /** * Collects config sources from plugins via the `b2c:config-sources` hook. * diff --git a/packages/b2c-tooling-sdk/src/telemetry/telemetry.ts b/packages/b2c-tooling-sdk/src/telemetry/telemetry.ts index c2fdee3f..109bd2ec 100644 --- a/packages/b2c-tooling-sdk/src/telemetry/telemetry.ts +++ b/packages/b2c-tooling-sdk/src/telemetry/telemetry.ts @@ -9,6 +9,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {TelemetryReporter} from '@salesforce/telemetry'; import type {TelemetryAttributes, TelemetryEventProperties, TelemetryOptions} from './types.js'; +import {getLogger, type Logger} from '../logging/index.js'; const generateRandomId = (): string => randomBytes(20).toString('hex'); @@ -104,6 +105,7 @@ export class Telemetry { private started: boolean; private version: string; private appInsightsKey: string | undefined; + private traceLog: Logger | undefined; /** * Check if telemetry is disabled via environment variables. @@ -132,12 +134,17 @@ export class Telemetry { this.sessionId = generateRandomId(); this.started = false; this.version = options.version ?? '0.0.0'; + + if (process.env.SFCC_TELEMETRY_LOG === 'true') { + this.traceLog = getLogger().child({component: 'telemetry'}); + } } /** * Add additional attributes to include with all future events. */ addAttributes(attributes: TelemetryAttributes): void { + this.traceLog?.debug({attributes}, 'telemetry addAttributes'); this.attributes = {...this.attributes, ...attributes}; } @@ -152,6 +159,7 @@ export class Telemetry { sendEvent(eventName: string, attributes: TelemetryAttributes = {}): void { try { const name = eventName?.trim() || 'UNKNOWN'; + this.traceLog?.debug({event: name, attributes}, 'telemetry sendEvent'); const eventProperties = this.buildEventProperties(attributes); this.reporter?.sendTelemetryEvent(name, eventProperties); } catch { @@ -181,6 +189,7 @@ export class Telemetry { */ sendException(error: Error, attributes: TelemetryAttributes = {}): void { try { + this.traceLog?.debug({error: error.name, message: error.message}, 'telemetry sendException'); const properties = this.buildEventProperties(sanitizeAttributes(attributes)); this.reporter?.sendTelemetryException(error, properties); } catch { @@ -199,6 +208,11 @@ export class Telemetry { // If no key provided, telemetry is disabled if (!this.appInsightsKey) return; + this.traceLog?.debug( + {project: this.project, sessionId: this.sessionId, cliId: this.cliId.slice(0, 8) + '...'}, + 'telemetry start', + ); + try { await this.createReporter(); } catch { @@ -244,6 +258,7 @@ export class Telemetry { */ async stop(): Promise { if (!this.started) return; + this.traceLog?.debug('telemetry stop'); this.started = false; this.reporter?.stop(); diff --git a/packages/b2c-tooling-sdk/test/cli/base-command.test.ts b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts index 67f1025f..8d74e670 100644 --- a/packages/b2c-tooling-sdk/test/cli/base-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts @@ -37,6 +37,18 @@ class TestBaseCommand extends BaseCommand { public getTelemetry() { return this.telemetry; } + + public getResolvedConfig() { + return this.resolvedConfig; + } + + public setResolvedConfig(config: typeof this.resolvedConfig) { + this.resolvedConfig = config; + } + + public testAddTelemetryContext() { + return this.addTelemetryContext(); + } } describe('cli/base-command', () => { @@ -454,6 +466,193 @@ describe('cli/base-command', () => { }); }); + describe('addTelemetryContext', () => { + let telemetryAddAttributesStub: sinon.SinonStub; + + beforeEach(() => { + telemetryAddAttributesStub = sinon.stub(Telemetry.prototype, 'addAttributes'); + }); + + function setupTelemetry(cmd: TestBaseCommand): void { + const telemetry = new Telemetry({project: 'test', appInsightsKey: 'test-key'}); + (cmd as unknown as {telemetry: Telemetry}).telemetry = telemetry; + } + + function setResolvedConfig( + cmd: TestBaseCommand, + values: Record, + sources: {name: string; fields: string[]}[] = [], + ): void { + cmd.setResolvedConfig({ + values: values as unknown as ReturnType['values'], + sources: sources as unknown as ReturnType['sources'], + warnings: [], + hasB2CInstanceConfig: () => false, + hasMrtConfig: () => false, + hasOAuthConfig: () => false, + hasBasicAuthConfig: () => false, + createB2CInstance: () => { + throw new Error('not implemented'); + }, + createBasicAuth: () => { + throw new Error('not implemented'); + }, + createOAuth: () => { + throw new Error('not implemented'); + }, + createMrtAuth: () => { + throw new Error('not implemented'); + }, + createWebDavAuth: () => { + throw new Error('not implemented'); + }, + }); + } + + it('adds realm and tenantId from tenantId config', async () => { + stubParse(command); + await command.init(); + setupTelemetry(command); + setResolvedConfig(command, {tenantId: 'zzpq_019'}); + + command.testAddTelemetryContext(); + + expect(telemetryAddAttributesStub.calledOnce).to.be.true; + const attrs = telemetryAddAttributesStub.firstCall.args[0]; + expect(attrs.realm).to.equal('zzpq'); + expect(attrs.tenantId).to.equal('zzpq_019'); + }); + + it('extracts realm from hostname when no tenantId', async () => { + stubParse(command); + await command.init(); + setupTelemetry(command); + setResolvedConfig(command, {hostname: 'zzpq-019.dx.commercecloud.salesforce.com'}); + + command.testAddTelemetryContext(); + + expect(telemetryAddAttributesStub.calledOnce).to.be.true; + const attrs = telemetryAddAttributesStub.firstCall.args[0]; + expect(attrs.realm).to.equal('zzpq'); + expect(attrs.hostname).to.equal('zzpq-019.dx.commercecloud.salesforce.com'); + expect(attrs.tenantId).to.be.undefined; + }); + + it('adds shortCode when available', async () => { + stubParse(command); + await command.init(); + setupTelemetry(command); + setResolvedConfig(command, {tenantId: 'zzpq_019', shortCode: 'kv7kzm78'}); + + command.testAddTelemetryContext(); + + expect(telemetryAddAttributesStub.calledOnce).to.be.true; + const attrs = telemetryAddAttributesStub.firstCall.args[0]; + expect(attrs.shortCode).to.equal('kv7kzm78'); + }); + + it('adds hostname when available', async () => { + stubParse(command); + await command.init(); + setupTelemetry(command); + setResolvedConfig(command, {tenantId: 'zzpq_019', hostname: 'zzpq-019.dx.commercecloud.salesforce.com'}); + + command.testAddTelemetryContext(); + + expect(telemetryAddAttributesStub.calledOnce).to.be.true; + const attrs = telemetryAddAttributesStub.firstCall.args[0]; + expect(attrs.hostname).to.equal('zzpq-019.dx.commercecloud.salesforce.com'); + }); + + it('adds clientId when available', async () => { + stubParse(command); + await command.init(); + setupTelemetry(command); + setResolvedConfig(command, {tenantId: 'zzpq_019', clientId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}); + + command.testAddTelemetryContext(); + + expect(telemetryAddAttributesStub.calledOnce).to.be.true; + const attrs = telemetryAddAttributesStub.firstCall.args[0]; + expect(attrs.clientId).to.equal('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + }); + + it('adds configSources from resolved sources', async () => { + stubParse(command); + await command.init(); + setupTelemetry(command); + setResolvedConfig(command, {tenantId: 'zzpq_019'}, [ + {name: 'flags', fields: ['tenantId']}, + {name: 'dw.json', fields: ['hostname']}, + ]); + + command.testAddTelemetryContext(); + + expect(telemetryAddAttributesStub.calledOnce).to.be.true; + const attrs = telemetryAddAttributesStub.firstCall.args[0]; + expect(attrs.configSources).to.equal('flags, dw.json'); + }); + + it('does not call addAttributes when no context available', async () => { + stubParse(command); + await command.init(); + setupTelemetry(command); + setResolvedConfig(command, {}); + + command.testAddTelemetryContext(); + + expect(telemetryAddAttributesStub.called).to.be.false; + }); + + it('handles f_ecom_ prefixed tenantId', async () => { + stubParse(command); + await command.init(); + setupTelemetry(command); + setResolvedConfig(command, {tenantId: 'f_ecom_zzpq_019'}); + + command.testAddTelemetryContext(); + + expect(telemetryAddAttributesStub.calledOnce).to.be.true; + const attrs = telemetryAddAttributesStub.firstCall.args[0]; + expect(attrs.realm).to.equal('zzpq'); + expect(attrs.tenantId).to.equal('f_ecom_zzpq_019'); + }); + + it('handles unparseable tenantId (sets tenantId but not realm)', async () => { + stubParse(command); + await command.init(); + setupTelemetry(command); + setResolvedConfig(command, {tenantId: 'some-custom-id-format'}); + + command.testAddTelemetryContext(); + + expect(telemetryAddAttributesStub.calledOnce).to.be.true; + const attrs = telemetryAddAttributesStub.firstCall.args[0]; + expect(attrs.tenantId).to.equal('some-custom-id-format'); + expect(attrs.realm).to.be.undefined; + }); + + it('does not throw when resolvedConfig has unexpected shape', async () => { + stubParse(command); + await command.init(); + setupTelemetry(command); + // Force a broken resolvedConfig to simulate unexpected runtime state + command.setResolvedConfig(null as unknown as ReturnType); + + expect(() => command.testAddTelemetryContext()).to.not.throw(); + }); + + it('does nothing when telemetry is not initialized', async () => { + stubParse(command); + await command.init(); + setResolvedConfig(command, {tenantId: 'zzpq_019'}); + + command.testAddTelemetryContext(); + + expect(telemetryAddAttributesStub.called).to.be.false; + }); + }); + describe('finally() success tracking', () => { it('sends COMMAND_SUCCESS when no error occurred', async () => { const telemetry = new Telemetry({project: 'test', appInsightsKey: 'test-key'}); diff --git a/packages/b2c-tooling-sdk/test/telemetry/telemetry.test.ts b/packages/b2c-tooling-sdk/test/telemetry/telemetry.test.ts index c9121c00..e3fe1ce3 100644 --- a/packages/b2c-tooling-sdk/test/telemetry/telemetry.test.ts +++ b/packages/b2c-tooling-sdk/test/telemetry/telemetry.test.ts @@ -11,6 +11,7 @@ import path from 'node:path'; import type {TelemetryReporter} from '@salesforce/telemetry'; import * as telemetryModule from '@salesforce/telemetry'; import {Telemetry, createTelemetry} from '@salesforce/b2c-tooling-sdk/telemetry'; +import {configureLogger, resetLogger} from '@salesforce/b2c-tooling-sdk/logging'; /** Type for TelemetryReporter.create options */ interface ReporterCreateOptions { @@ -985,6 +986,76 @@ describe('telemetry/telemetry', () => { }); }); + describe('debug logging (SFCC_TELEMETRY_LOG)', () => { + let originalTelemetryLog: string | undefined; + let tmpDir: string; + + beforeEach(() => { + originalTelemetryLog = process.env.SFCC_TELEMETRY_LOG; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-log-test-')); + }); + + afterEach(() => { + if (originalTelemetryLog !== undefined) { + process.env.SFCC_TELEMETRY_LOG = originalTelemetryLog; + } else { + delete process.env.SFCC_TELEMETRY_LOG; + } + resetLogger(); + fs.rmSync(tmpDir, {recursive: true, force: true}); + }); + + it('logs telemetry events when SFCC_TELEMETRY_LOG=true', async () => { + process.env.SFCC_TELEMETRY_LOG = 'true'; + + const logFile = path.join(tmpDir, 'log.jsonl'); + configureLogger({level: 'debug', json: true, fd: fs.openSync(logFile, 'w')}); + + const mockReporter = createMockReporter(sandbox); + sandbox.stub(telemetryModule.TelemetryReporter, 'create').resolves(asTelemetryReporter(mockReporter)); + + const telemetry = new Telemetry({ + project: 'test-project', + appInsightsKey: 'test-key', + }); + + await telemetry.start(); + telemetry.addAttributes({realm: 'zzpq'}); + telemetry.sendEvent('COMMAND_START', {command: 'test'}); + telemetry.sendException(new Error('test error')); + await telemetry.stop(); + + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).to.include('telemetry start'); + expect(logContent).to.include('telemetry addAttributes'); + expect(logContent).to.include('telemetry sendEvent'); + expect(logContent).to.include('telemetry sendException'); + expect(logContent).to.include('telemetry stop'); + }); + + it('does not log when SFCC_TELEMETRY_LOG is not set', async () => { + delete process.env.SFCC_TELEMETRY_LOG; + + const logFile = path.join(tmpDir, 'log.jsonl'); + configureLogger({level: 'debug', json: true, fd: fs.openSync(logFile, 'w')}); + + const mockReporter = createMockReporter(sandbox); + sandbox.stub(telemetryModule.TelemetryReporter, 'create').resolves(asTelemetryReporter(mockReporter)); + + const telemetry = new Telemetry({ + project: 'test-project', + appInsightsKey: 'test-key', + }); + + await telemetry.start(); + telemetry.sendEvent('COMMAND_START'); + await telemetry.stop(); + + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).to.not.include('telemetry'); + }); + }); + describe('integration scenarios', () => { it('supports full CLI command lifecycle', async () => { const mockReporter = createMockReporter(sandbox);