Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/b2c-tooling-sdk/src/cli/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -167,6 +168,8 @@ export abstract class BaseCommand<T extends typeof Command> extends Command {
await this.initTelemetryFromConfig();

this.resolvedConfig = this.loadConfiguration();

this.addTelemetryContext();
}

/**
Expand Down Expand Up @@ -325,6 +328,63 @@ export abstract class BaseCommand<T extends typeof Command> 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.
*
Expand Down
15 changes: 15 additions & 0 deletions packages/b2c-tooling-sdk/src/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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};
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -244,6 +258,7 @@ export class Telemetry {
*/
async stop(): Promise<void> {
if (!this.started) return;
this.traceLog?.debug('telemetry stop');
this.started = false;
this.reporter?.stop();

Expand Down
199 changes: 199 additions & 0 deletions packages/b2c-tooling-sdk/test/cli/base-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ class TestBaseCommand extends BaseCommand<typeof TestBaseCommand> {
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', () => {
Expand Down Expand Up @@ -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<string, string | undefined>,
sources: {name: string; fields: string[]}[] = [],
): void {
cmd.setResolvedConfig({
values: values as unknown as ReturnType<TestBaseCommand['getResolvedConfig']>['values'],
sources: sources as unknown as ReturnType<TestBaseCommand['getResolvedConfig']>['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<typeof command.getResolvedConfig>);

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'});
Expand Down
Loading
Loading