Skip to content

Commit 6433854

Browse files
Merge pull request #1287 from chromaui/CAP-4317-analytics
Send analytics for Storybook build failures
2 parents 2d006c6 + c25a9c3 commit 6433854

15 files changed

Lines changed: 611 additions & 16 deletions

node-src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,13 @@ async function runBuild(ctx: Context) {
276276
}
277277

278278
ctx.log.flush();
279+
280+
try {
281+
await ctx.analytics?.shutdown();
282+
} catch (error) {
283+
// Analytics shutdown should never crash the CLI, but we want to know about it
284+
Sentry.captureException(error);
285+
}
279286
}
280287
} catch (error) {
281288
const errors = [error].flat(); // GraphQLClient might throw an array of errors

node-src/lib/analytics/events.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const AnalyticsEvent = {
2+
CLI_STORYBOOK_BUILD_FAILED: 'CLI_STORYBOOK_BUILD_FAILED',
3+
} as const;
4+
5+
export type AnalyticsEvent = (typeof AnalyticsEvent)[keyof typeof AnalyticsEvent];
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
3+
import TestLogger from '../testLogger';
4+
import { createAnalyticsClient } from './index';
5+
import { IndexAnalyticsClient } from './indexClient';
6+
import { LogOnlyAnalyticsClient } from './logOnly';
7+
8+
function makeContext() {
9+
return {
10+
log: new TestLogger(),
11+
client: { runQuery: vi.fn() },
12+
} as any;
13+
}
14+
15+
describe('createAnalyticsClient', () => {
16+
afterEach(() => {
17+
delete process.env.CHROMATIC_DISABLE_ANALYTICS;
18+
});
19+
20+
it('returns an Index client when CHROMATIC_DISABLE_ANALYTICS is not set', () => {
21+
const ctx = makeContext();
22+
const client = createAnalyticsClient(ctx);
23+
expect(client).toBeInstanceOf(IndexAnalyticsClient);
24+
});
25+
26+
it('returns a log-only client when CHROMATIC_DISABLE_ANALYTICS is set', () => {
27+
process.env.CHROMATIC_DISABLE_ANALYTICS = 'true';
28+
const ctx = makeContext();
29+
const client = createAnalyticsClient(ctx);
30+
expect(client).toBeInstanceOf(LogOnlyAnalyticsClient);
31+
});
32+
});

node-src/lib/analytics/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Context } from '../../types';
2+
import { IndexAnalyticsClient } from './indexClient';
3+
import { LogOnlyAnalyticsClient } from './logOnly';
4+
import type { AnalyticsClient } from './types';
5+
6+
export type { AnalyticsClient } from './types';
7+
8+
/**
9+
* Creates an analytics client.
10+
*
11+
* @param ctx The context set when executing the CLI.
12+
*
13+
* @returns An analytics client instance.
14+
*/
15+
export function createAnalyticsClient(ctx: Context): AnalyticsClient {
16+
if (process.env.CHROMATIC_DISABLE_ANALYTICS === 'true') {
17+
ctx.log.debug('[analytics] disabled via CHROMATIC_DISABLE_ANALYTICS, using log-only client');
18+
return new LogOnlyAnalyticsClient(ctx.log);
19+
}
20+
21+
ctx.log.debug('[analytics] initializing Index analytics client');
22+
return new IndexAnalyticsClient({ client: ctx.client, logger: ctx.log });
23+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import * as Sentry from '@sentry/node';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import TestLogger from '../testLogger';
5+
import { AnalyticsEvent } from './events';
6+
import { IndexAnalyticsClient } from './indexClient';
7+
8+
vi.mock('@sentry/node', () => ({
9+
captureException: vi.fn(),
10+
}));
11+
12+
function makeClient(runQuery = vi.fn().mockResolvedValue({ trackEvent: { success: true } })) {
13+
const gqlClient = { runQuery } as any;
14+
const logger = new TestLogger();
15+
const client = new IndexAnalyticsClient({ client: gqlClient, logger });
16+
return { client, gqlClient, logger, runQuery };
17+
}
18+
19+
describe('IndexAnalyticsClient', () => {
20+
beforeEach(() => {
21+
vi.mocked(Sentry.captureException).mockClear();
22+
});
23+
24+
describe('trackEvent', () => {
25+
it('calls runQuery with TrackCLITelemetryEvent mutation and input built from properties', () => {
26+
const { client, runQuery } = makeClient();
27+
28+
client.trackEvent(AnalyticsEvent.CLI_STORYBOOK_BUILD_FAILED, {
29+
errorCategory: 'storybook_build_failed',
30+
});
31+
32+
expect(runQuery).toHaveBeenCalledWith(
33+
expect.stringMatching(/TrackCLITelemetryEvent/),
34+
{
35+
input: {
36+
event: AnalyticsEvent.CLI_STORYBOOK_BUILD_FAILED,
37+
properties: { errorCategory: 'storybook_build_failed' },
38+
},
39+
},
40+
{ retries: 0 }
41+
);
42+
});
43+
44+
it('swallows GQL errors and reports to Sentry', async () => {
45+
const error = new Error('GQL failed');
46+
const { client } = makeClient(vi.fn().mockRejectedValue(error));
47+
48+
expect(() => client.trackEvent(AnalyticsEvent.CLI_STORYBOOK_BUILD_FAILED, {})).not.toThrow();
49+
50+
await client.shutdown();
51+
52+
expect(Sentry.captureException).toHaveBeenCalledWith(error);
53+
});
54+
});
55+
56+
describe('shutdown', () => {
57+
it('awaits all pending in-flight requests', async () => {
58+
const resolvers: (() => void)[] = [];
59+
const runQuery = vi.fn(
60+
() =>
61+
new Promise((resolve) => {
62+
resolvers.push(() => resolve({ trackEvent: { success: true } }));
63+
})
64+
);
65+
const { client } = makeClient(runQuery);
66+
67+
client.trackEvent(AnalyticsEvent.CLI_STORYBOOK_BUILD_FAILED, {});
68+
client.trackEvent(AnalyticsEvent.CLI_STORYBOOK_BUILD_FAILED, {});
69+
70+
let resolved = false;
71+
const shutdownPromise = client.shutdown().then(() => {
72+
resolved = true;
73+
});
74+
75+
// Task flush: force `.then(() => resolved = true)` above to fire if `shutdown` has returned already resolved promise
76+
await Promise.resolve();
77+
expect(resolved).toBe(false);
78+
79+
for (const resolve of resolvers) resolve();
80+
await shutdownPromise;
81+
expect(resolved).toBe(true);
82+
});
83+
84+
it('times out after 5s when requests hang', async () => {
85+
vi.useFakeTimers();
86+
try {
87+
const runQuery = vi.fn(() => new Promise(() => {}));
88+
const { client, logger } = makeClient(runQuery);
89+
90+
client.trackEvent(AnalyticsEvent.CLI_STORYBOOK_BUILD_FAILED, {});
91+
92+
const shutdownPromise = client.shutdown();
93+
vi.advanceTimersByTime(5000);
94+
await shutdownPromise;
95+
expect(logger.debug).toHaveBeenCalledWith(
96+
'[analytics] shutdown timed out before all events flushed'
97+
);
98+
} finally {
99+
vi.useRealTimers();
100+
}
101+
});
102+
});
103+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as Sentry from '@sentry/node';
2+
3+
import GraphQLClient from '../../io/graphqlClient';
4+
import { Logger } from '../log';
5+
import type { AnalyticsEvent } from './events';
6+
import type { AnalyticsClient } from './types';
7+
8+
const TrackCLITelemetryEventMutation = `
9+
mutation TrackCLITelemetryEvent($input: TrackCLITelemetryEventInput!) {
10+
trackCLITelemetryEvent(input: $input)
11+
}
12+
`;
13+
14+
const SHUTDOWN_TIMEOUT_MS = 5000;
15+
16+
interface IndexAnalyticsOptions {
17+
client: GraphQLClient;
18+
logger: Logger;
19+
}
20+
21+
/** Analytics client that sends events to the Chromatic Index via GraphQL. */
22+
export class IndexAnalyticsClient implements AnalyticsClient {
23+
private client: GraphQLClient;
24+
private logger: Logger;
25+
private pending: Promise<unknown>[] = [];
26+
27+
constructor({ client, logger }: IndexAnalyticsOptions) {
28+
this.client = client;
29+
this.logger = logger;
30+
}
31+
32+
trackEvent(eventName: AnalyticsEvent, properties?: Record<string, unknown>): void {
33+
this.logger.debug(`[analytics] trackEvent: ${eventName}`, JSON.stringify(properties));
34+
35+
const input = { event: eventName, properties };
36+
37+
const promise = this.client
38+
// Sending analytics is best effort, so we'll skip retries to keep things fast
39+
.runQuery(TrackCLITelemetryEventMutation, { input }, { retries: 0 })
40+
.catch((err) => {
41+
this.logger.debug('[analytics] trackEvent failed', err);
42+
Sentry.captureException(err);
43+
});
44+
45+
this.pending.push(promise);
46+
}
47+
48+
async shutdown(): Promise<void> {
49+
// Currently no cap on the number of pending events. YAGNI. Will add batching/capping later if it becomes an issue.
50+
this.logger.debug(`[analytics] shutdown: flushing ${this.pending.length} pending event(s)`);
51+
52+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
53+
const timeout = new Promise<void>((resolve) => {
54+
timeoutId = setTimeout(() => {
55+
this.logger.debug('[analytics] shutdown timed out before all events flushed');
56+
resolve();
57+
}, SHUTDOWN_TIMEOUT_MS);
58+
});
59+
60+
await Promise.race([
61+
Promise.all(this.pending).then(() => {
62+
this.logger.debug('[analytics] shutdown: finished flushing pending events');
63+
}),
64+
timeout,
65+
]);
66+
if (timeoutId) clearTimeout(timeoutId);
67+
}
68+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import TestLogger from '../testLogger';
4+
import { AnalyticsEvent } from './events';
5+
import { LogOnlyAnalyticsClient } from './logOnly';
6+
7+
describe('LogOnlyAnalyticsClient', () => {
8+
it('debug-logs tracked events with properties', () => {
9+
const logger = new TestLogger();
10+
const client = new LogOnlyAnalyticsClient(logger);
11+
12+
const properties = { errorCategory: 'storybook_build_failed' };
13+
client.trackEvent(AnalyticsEvent.CLI_STORYBOOK_BUILD_FAILED, properties);
14+
15+
expect(logger.debug).toHaveBeenCalledWith(
16+
expect.stringContaining(AnalyticsEvent.CLI_STORYBOOK_BUILD_FAILED),
17+
JSON.stringify(properties)
18+
);
19+
});
20+
});

node-src/lib/analytics/logOnly.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Logger } from '../log';
2+
import type { AnalyticsEvent } from './events';
3+
import type { AnalyticsClient } from './types';
4+
5+
/** Analytics client that debug-logs events. */
6+
export class LogOnlyAnalyticsClient implements AnalyticsClient {
7+
private logger: Logger;
8+
9+
constructor(logger: Logger) {
10+
this.logger = logger;
11+
}
12+
13+
trackEvent(eventName: AnalyticsEvent, properties?: Record<string, unknown>): void {
14+
this.logger.debug(`[analytics] trackEvent: ${eventName}`, JSON.stringify(properties));
15+
}
16+
17+
async shutdown(): Promise<void> {
18+
this.logger.debug('[analytics] shutdown');
19+
}
20+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { sanitizeStackTrace } from './sanitization';
4+
5+
describe('sanitizeStackTrace', () => {
6+
const cases: { name: string; input: string | undefined; expected: string }[] = [
7+
{ name: 'undefined input', input: undefined, expected: '' },
8+
{ name: 'empty string input', input: '', expected: '' },
9+
{
10+
name: 'redacts double-quoted content',
11+
input: 'Error: "secret value" found',
12+
expected: 'Error: <redacted> found',
13+
},
14+
{
15+
name: 'redacts backtick content',
16+
input: 'Error: `token abc` found',
17+
expected: 'Error: <redacted> found',
18+
},
19+
{
20+
name: 'preserves single-quoted content (apostrophes)',
21+
input: "Error: can't find module 'foo'",
22+
expected: "Error: can't find module 'foo'",
23+
},
24+
{
25+
name: 'redacts email addresses',
26+
input: 'Error: contact user.name+tag@example.co.uk for help',
27+
expected: 'Error: contact <email> for help',
28+
},
29+
{
30+
name: 'redacts home path preserving filename and :line:col',
31+
input: 'at fn ~/project/src/file.js:10:5',
32+
expected: 'at fn <path>/file.js:10:5',
33+
},
34+
{
35+
name: 'redacts home path preserving filename without line:col',
36+
input: 'at fn ~/project/src/file.js',
37+
expected: 'at fn <path>/file.js',
38+
},
39+
{
40+
name: 'redacts ~user home path preserving filename',
41+
input: 'at fn ~user/project/file.js:3:1',
42+
expected: 'at fn <path>/file.js:3:1',
43+
},
44+
{
45+
name: 'redacts unix absolute path preserving filename and :line:col',
46+
input: 'at fn /Users/user/project/file.js:42:7',
47+
expected: 'at fn <path>/file.js:42:7',
48+
},
49+
{
50+
name: 'redacts unix absolute path preserving filename without line:col',
51+
input: 'at fn /Users/user/project/file.js',
52+
expected: 'at fn <path>/file.js',
53+
},
54+
{
55+
name: 'redacts windows path preserving filename and :line:col',
56+
input: String.raw`at fn C:\Users\user\project\file.js:12:4`,
57+
expected: 'at fn <path>/file.js:12:4',
58+
},
59+
{
60+
name: 'redacts windows path preserving filename without line:col',
61+
input: String.raw`at fn D:\work\file.js`,
62+
expected: 'at fn <path>/file.js',
63+
},
64+
{
65+
name: 'preserves filename for single-segment absolute path',
66+
input: 'at fn /foo',
67+
expected: 'at fn <path>/foo', // not worth the regex complexity to handle this very unlikely weird case
68+
},
69+
{
70+
name: 'does not eat closing paren around path',
71+
input: 'at fn (/Users/alice/file.js:1:1)',
72+
expected: 'at fn (<path>/file.js:1:1)',
73+
},
74+
{
75+
name: 'handles multi-line stack trace without eating across lines',
76+
input: 'at x (/a/first.js:1:1)\n at y (/b/second.js:2:2)',
77+
expected: 'at x (<path>/first.js:1:1)\n at y (<path>/second.js:2:2)',
78+
},
79+
{
80+
name: 'caps input at 8000 chars before sanitization',
81+
input: 'a'.repeat(10_000),
82+
expected: 'a'.repeat(8000),
83+
},
84+
];
85+
86+
it.each(cases)('$name', ({ input, expected }) => {
87+
expect(sanitizeStackTrace(input)).toBe(expected);
88+
});
89+
});

0 commit comments

Comments
 (0)