Skip to content

Commit ad56eda

Browse files
authored
Add realm/tenant telemetry context and opt-in debug logging (#140)
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.
1 parent 91593f2 commit ad56eda

4 files changed

Lines changed: 345 additions & 0 deletions

File tree

packages/b2c-tooling-sdk/src/cli/base-command.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {Command, Flags, type Interfaces} from '@oclif/core';
77
import {loadConfig} from './config.js';
88
import type {LoadConfigOptions, PluginSources} from './config.js';
99
import type {ResolvedB2CConfig} from '../config/index.js';
10+
import {parseFriendlySandboxId} from '../operations/ods/sandbox-lookup.js';
1011
import type {
1112
ConfigSourcesHookOptions,
1213
ConfigSourcesHookResult,
@@ -167,6 +168,8 @@ export abstract class BaseCommand<T extends typeof Command> extends Command {
167168
await this.initTelemetryFromConfig();
168169

169170
this.resolvedConfig = this.loadConfiguration();
171+
172+
this.addTelemetryContext();
170173
}
171174

172175
/**
@@ -325,6 +328,63 @@ export abstract class BaseCommand<T extends typeof Command> extends Command {
325328
return loadConfig({}, this.getBaseConfigOptions(), this.getPluginSources());
326329
}
327330

331+
/**
332+
* Enrich telemetry with realm/tenant context from the resolved configuration.
333+
* Called after loadConfiguration() in init() so that COMMAND_SUCCESS and
334+
* COMMAND_EXCEPTION events include organizational context.
335+
*/
336+
protected addTelemetryContext(): void {
337+
if (!this.telemetry) return;
338+
339+
try {
340+
const attributes: TelemetryAttributes = {};
341+
const {values, sources} = this.resolvedConfig;
342+
343+
// Extract realm from tenantId (e.g., "zzpq_019" or "f_ecom_zzpq_019")
344+
if (values.tenantId) {
345+
attributes.tenantId = values.tenantId;
346+
const parsed = parseFriendlySandboxId(values.tenantId);
347+
if (parsed) {
348+
attributes.realm = parsed.realm;
349+
}
350+
}
351+
352+
// Fallback: extract realm from hostname (e.g., "zzpq-019.dx.commercecloud.salesforce.com")
353+
if (!attributes.realm && values.hostname) {
354+
const parsed = parseFriendlySandboxId(values.hostname.split('.')[0]);
355+
if (parsed) {
356+
attributes.realm = parsed.realm;
357+
}
358+
}
359+
360+
if (values.hostname) {
361+
attributes.hostname = values.hostname;
362+
}
363+
364+
if (values.clientId) {
365+
attributes.clientId = values.clientId;
366+
}
367+
368+
if (values.shortCode) {
369+
attributes.shortCode = values.shortCode;
370+
}
371+
372+
// Record which config sources contributed
373+
if (sources.length > 0) {
374+
attributes.configSources = sources.map((s) => s.name).join(', ');
375+
}
376+
377+
if (Object.keys(attributes).length > 0) {
378+
this.telemetry.addAttributes(attributes);
379+
if (process.env.SFCC_TELEMETRY_LOG === 'true') {
380+
this.logger.debug({attributes}, 'telemetry context enriched');
381+
}
382+
}
383+
} catch {
384+
// Best-effort: telemetry context enrichment must never prevent command execution
385+
}
386+
}
387+
328388
/**
329389
* Collects config sources from plugins via the `b2c:config-sources` hook.
330390
*

packages/b2c-tooling-sdk/src/telemetry/telemetry.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import fs from 'node:fs';
99
import path from 'node:path';
1010
import {TelemetryReporter} from '@salesforce/telemetry';
1111
import type {TelemetryAttributes, TelemetryEventProperties, TelemetryOptions} from './types.js';
12+
import {getLogger, type Logger} from '../logging/index.js';
1213

1314
const generateRandomId = (): string => randomBytes(20).toString('hex');
1415

@@ -104,6 +105,7 @@ export class Telemetry {
104105
private started: boolean;
105106
private version: string;
106107
private appInsightsKey: string | undefined;
108+
private traceLog: Logger | undefined;
107109

108110
/**
109111
* Check if telemetry is disabled via environment variables.
@@ -132,12 +134,17 @@ export class Telemetry {
132134
this.sessionId = generateRandomId();
133135
this.started = false;
134136
this.version = options.version ?? '0.0.0';
137+
138+
if (process.env.SFCC_TELEMETRY_LOG === 'true') {
139+
this.traceLog = getLogger().child({component: 'telemetry'});
140+
}
135141
}
136142

137143
/**
138144
* Add additional attributes to include with all future events.
139145
*/
140146
addAttributes(attributes: TelemetryAttributes): void {
147+
this.traceLog?.debug({attributes}, 'telemetry addAttributes');
141148
this.attributes = {...this.attributes, ...attributes};
142149
}
143150

@@ -152,6 +159,7 @@ export class Telemetry {
152159
sendEvent(eventName: string, attributes: TelemetryAttributes = {}): void {
153160
try {
154161
const name = eventName?.trim() || 'UNKNOWN';
162+
this.traceLog?.debug({event: name, attributes}, 'telemetry sendEvent');
155163
const eventProperties = this.buildEventProperties(attributes);
156164
this.reporter?.sendTelemetryEvent(name, eventProperties);
157165
} catch {
@@ -181,6 +189,7 @@ export class Telemetry {
181189
*/
182190
sendException(error: Error, attributes: TelemetryAttributes = {}): void {
183191
try {
192+
this.traceLog?.debug({error: error.name, message: error.message}, 'telemetry sendException');
184193
const properties = this.buildEventProperties(sanitizeAttributes(attributes));
185194
this.reporter?.sendTelemetryException(error, properties);
186195
} catch {
@@ -199,6 +208,11 @@ export class Telemetry {
199208
// If no key provided, telemetry is disabled
200209
if (!this.appInsightsKey) return;
201210

211+
this.traceLog?.debug(
212+
{project: this.project, sessionId: this.sessionId, cliId: this.cliId.slice(0, 8) + '...'},
213+
'telemetry start',
214+
);
215+
202216
try {
203217
await this.createReporter();
204218
} catch {
@@ -244,6 +258,7 @@ export class Telemetry {
244258
*/
245259
async stop(): Promise<void> {
246260
if (!this.started) return;
261+
this.traceLog?.debug('telemetry stop');
247262
this.started = false;
248263
this.reporter?.stop();
249264

packages/b2c-tooling-sdk/test/cli/base-command.test.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ class TestBaseCommand extends BaseCommand<typeof TestBaseCommand> {
3737
public getTelemetry() {
3838
return this.telemetry;
3939
}
40+
41+
public getResolvedConfig() {
42+
return this.resolvedConfig;
43+
}
44+
45+
public setResolvedConfig(config: typeof this.resolvedConfig) {
46+
this.resolvedConfig = config;
47+
}
48+
49+
public testAddTelemetryContext() {
50+
return this.addTelemetryContext();
51+
}
4052
}
4153

4254
describe('cli/base-command', () => {
@@ -454,6 +466,193 @@ describe('cli/base-command', () => {
454466
});
455467
});
456468

469+
describe('addTelemetryContext', () => {
470+
let telemetryAddAttributesStub: sinon.SinonStub;
471+
472+
beforeEach(() => {
473+
telemetryAddAttributesStub = sinon.stub(Telemetry.prototype, 'addAttributes');
474+
});
475+
476+
function setupTelemetry(cmd: TestBaseCommand): void {
477+
const telemetry = new Telemetry({project: 'test', appInsightsKey: 'test-key'});
478+
(cmd as unknown as {telemetry: Telemetry}).telemetry = telemetry;
479+
}
480+
481+
function setResolvedConfig(
482+
cmd: TestBaseCommand,
483+
values: Record<string, string | undefined>,
484+
sources: {name: string; fields: string[]}[] = [],
485+
): void {
486+
cmd.setResolvedConfig({
487+
values: values as unknown as ReturnType<TestBaseCommand['getResolvedConfig']>['values'],
488+
sources: sources as unknown as ReturnType<TestBaseCommand['getResolvedConfig']>['sources'],
489+
warnings: [],
490+
hasB2CInstanceConfig: () => false,
491+
hasMrtConfig: () => false,
492+
hasOAuthConfig: () => false,
493+
hasBasicAuthConfig: () => false,
494+
createB2CInstance: () => {
495+
throw new Error('not implemented');
496+
},
497+
createBasicAuth: () => {
498+
throw new Error('not implemented');
499+
},
500+
createOAuth: () => {
501+
throw new Error('not implemented');
502+
},
503+
createMrtAuth: () => {
504+
throw new Error('not implemented');
505+
},
506+
createWebDavAuth: () => {
507+
throw new Error('not implemented');
508+
},
509+
});
510+
}
511+
512+
it('adds realm and tenantId from tenantId config', async () => {
513+
stubParse(command);
514+
await command.init();
515+
setupTelemetry(command);
516+
setResolvedConfig(command, {tenantId: 'zzpq_019'});
517+
518+
command.testAddTelemetryContext();
519+
520+
expect(telemetryAddAttributesStub.calledOnce).to.be.true;
521+
const attrs = telemetryAddAttributesStub.firstCall.args[0];
522+
expect(attrs.realm).to.equal('zzpq');
523+
expect(attrs.tenantId).to.equal('zzpq_019');
524+
});
525+
526+
it('extracts realm from hostname when no tenantId', async () => {
527+
stubParse(command);
528+
await command.init();
529+
setupTelemetry(command);
530+
setResolvedConfig(command, {hostname: 'zzpq-019.dx.commercecloud.salesforce.com'});
531+
532+
command.testAddTelemetryContext();
533+
534+
expect(telemetryAddAttributesStub.calledOnce).to.be.true;
535+
const attrs = telemetryAddAttributesStub.firstCall.args[0];
536+
expect(attrs.realm).to.equal('zzpq');
537+
expect(attrs.hostname).to.equal('zzpq-019.dx.commercecloud.salesforce.com');
538+
expect(attrs.tenantId).to.be.undefined;
539+
});
540+
541+
it('adds shortCode when available', async () => {
542+
stubParse(command);
543+
await command.init();
544+
setupTelemetry(command);
545+
setResolvedConfig(command, {tenantId: 'zzpq_019', shortCode: 'kv7kzm78'});
546+
547+
command.testAddTelemetryContext();
548+
549+
expect(telemetryAddAttributesStub.calledOnce).to.be.true;
550+
const attrs = telemetryAddAttributesStub.firstCall.args[0];
551+
expect(attrs.shortCode).to.equal('kv7kzm78');
552+
});
553+
554+
it('adds hostname when available', async () => {
555+
stubParse(command);
556+
await command.init();
557+
setupTelemetry(command);
558+
setResolvedConfig(command, {tenantId: 'zzpq_019', hostname: 'zzpq-019.dx.commercecloud.salesforce.com'});
559+
560+
command.testAddTelemetryContext();
561+
562+
expect(telemetryAddAttributesStub.calledOnce).to.be.true;
563+
const attrs = telemetryAddAttributesStub.firstCall.args[0];
564+
expect(attrs.hostname).to.equal('zzpq-019.dx.commercecloud.salesforce.com');
565+
});
566+
567+
it('adds clientId when available', async () => {
568+
stubParse(command);
569+
await command.init();
570+
setupTelemetry(command);
571+
setResolvedConfig(command, {tenantId: 'zzpq_019', clientId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'});
572+
573+
command.testAddTelemetryContext();
574+
575+
expect(telemetryAddAttributesStub.calledOnce).to.be.true;
576+
const attrs = telemetryAddAttributesStub.firstCall.args[0];
577+
expect(attrs.clientId).to.equal('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
578+
});
579+
580+
it('adds configSources from resolved sources', async () => {
581+
stubParse(command);
582+
await command.init();
583+
setupTelemetry(command);
584+
setResolvedConfig(command, {tenantId: 'zzpq_019'}, [
585+
{name: 'flags', fields: ['tenantId']},
586+
{name: 'dw.json', fields: ['hostname']},
587+
]);
588+
589+
command.testAddTelemetryContext();
590+
591+
expect(telemetryAddAttributesStub.calledOnce).to.be.true;
592+
const attrs = telemetryAddAttributesStub.firstCall.args[0];
593+
expect(attrs.configSources).to.equal('flags, dw.json');
594+
});
595+
596+
it('does not call addAttributes when no context available', async () => {
597+
stubParse(command);
598+
await command.init();
599+
setupTelemetry(command);
600+
setResolvedConfig(command, {});
601+
602+
command.testAddTelemetryContext();
603+
604+
expect(telemetryAddAttributesStub.called).to.be.false;
605+
});
606+
607+
it('handles f_ecom_ prefixed tenantId', async () => {
608+
stubParse(command);
609+
await command.init();
610+
setupTelemetry(command);
611+
setResolvedConfig(command, {tenantId: 'f_ecom_zzpq_019'});
612+
613+
command.testAddTelemetryContext();
614+
615+
expect(telemetryAddAttributesStub.calledOnce).to.be.true;
616+
const attrs = telemetryAddAttributesStub.firstCall.args[0];
617+
expect(attrs.realm).to.equal('zzpq');
618+
expect(attrs.tenantId).to.equal('f_ecom_zzpq_019');
619+
});
620+
621+
it('handles unparseable tenantId (sets tenantId but not realm)', async () => {
622+
stubParse(command);
623+
await command.init();
624+
setupTelemetry(command);
625+
setResolvedConfig(command, {tenantId: 'some-custom-id-format'});
626+
627+
command.testAddTelemetryContext();
628+
629+
expect(telemetryAddAttributesStub.calledOnce).to.be.true;
630+
const attrs = telemetryAddAttributesStub.firstCall.args[0];
631+
expect(attrs.tenantId).to.equal('some-custom-id-format');
632+
expect(attrs.realm).to.be.undefined;
633+
});
634+
635+
it('does not throw when resolvedConfig has unexpected shape', async () => {
636+
stubParse(command);
637+
await command.init();
638+
setupTelemetry(command);
639+
// Force a broken resolvedConfig to simulate unexpected runtime state
640+
command.setResolvedConfig(null as unknown as ReturnType<typeof command.getResolvedConfig>);
641+
642+
expect(() => command.testAddTelemetryContext()).to.not.throw();
643+
});
644+
645+
it('does nothing when telemetry is not initialized', async () => {
646+
stubParse(command);
647+
await command.init();
648+
setResolvedConfig(command, {tenantId: 'zzpq_019'});
649+
650+
command.testAddTelemetryContext();
651+
652+
expect(telemetryAddAttributesStub.called).to.be.false;
653+
});
654+
});
655+
457656
describe('finally() success tracking', () => {
458657
it('sends COMMAND_SUCCESS when no error occurred', async () => {
459658
const telemetry = new Telemetry({project: 'test', appInsightsKey: 'test-key'});

0 commit comments

Comments
 (0)