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
86 changes: 84 additions & 2 deletions code/core/src/telemetry/anonymous-id.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { normalizeGitUrl, unhashedProjectId } from './anonymous-id';
import { executeCommandSync } from 'storybook/internal/common';

import {
getAnonymousProjectId,
getProjectSince,
normalizeGitUrl,
unhashedProjectId,
} from './anonymous-id';

vi.mock(import('storybook/internal/common'), async (actualModule) => {
const actual = await actualModule();

return {
...actual,
executeCommandSync: vi.fn(actual.executeCommandSync),
getProjectRoot: () => '/path/to/project/root',
};
});

beforeEach(() => {
vi.mocked(executeCommandSync).mockReset();
});

describe('normalizeGitUrl', () => {
it('trims off https://', () => {
Expand Down Expand Up @@ -105,3 +126,64 @@ describe('unhashedProjectId', () => {
).toBe('github.com/storybookjs/storybook.gitpath/to/storybook');
});
});

describe('getProjectSince', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});

it('returns the Storybook creation date from git log output', () => {
vi.mocked(executeCommandSync).mockReturnValue(
'2025-12-11 16:24:01 +0530\n' + '2014-12-11 19:09:10 +0530'
);

expect(getProjectSince()).toEqual(new Date('2025-12-11T10:54:01.000Z'));
});

it('returns undefined if git log output is empty', async () => {
vi.mocked(executeCommandSync).mockReturnValue('');

const { getProjectSince: getProjSince } = await import('./anonymous-id');

expect(getProjSince()).toBeUndefined();
});

it('returns undefined if git log fails', async () => {
vi.mocked(executeCommandSync).mockImplementation(() => {
throw new Error('git not available');
});

const { getProjectSince: getProjSince } = await import('./anonymous-id');

expect(getProjSince()).toBeUndefined();
});
});
Comment on lines +130 to +161

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a test case to verify that when git log returns multiple lines, only the first line is parsed. This would ensure the implementation correctly handles the typical multi-commit scenario. For example:

vi.mocked(executeCommandSync).mockReturnValue('2015-12-11 10:54:01 +0000\n2016-01-01 12:00:00 +0000');
expect(getProjectSince()).toEqual(new Date('2015-12-11T10:54:01.000Z'));

Copilot uses AI. Check for mistakes.
Comment on lines +130 to +161

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a test case for when git log returns an invalid date string to verify that the NaN check at line 69 properly handles this edge case. For example:

vi.mocked(executeCommandSync).mockReturnValue('not a valid date');
expect(getProjectSince()).toBeUndefined();

Copilot uses AI. Check for mistakes.

describe('getAnonymousProjectId', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();

vi.spyOn(process, 'cwd').mockReturnValue('/path/to/project/root');
});

it('returns hashed project id for Storybook repo when git command succeeds', async () => {
vi.mocked(executeCommandSync).mockReturnValue('git@github.com:storybookjs/storybook.git');
const result = getAnonymousProjectId();

expect(result).toMatch('061e4ee22a1f7c079849d97234b3be94d016fb1f24ba11878c41f8b48c0213bf');
});

it('returns undefined when git command fails', async () => {
const { getAnonymousProjectId: getAnonId } = await import('./anonymous-id');

vi.mocked(executeCommandSync).mockImplementation(() => {
throw new Error('git not available');
});

const result = getAnonId();

expect(result).toBeUndefined();
});
});
41 changes: 36 additions & 5 deletions code/core/src/telemetry/anonymous-id.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { relative } from 'node:path';

import { getProjectRoot } from 'storybook/internal/common';
import { executeCommandSync, getProjectRoot } from 'storybook/internal/common';

import { execSync } from 'child_process';
// eslint-disable-next-line depend/ban-dependencies
import slash from 'slash';

Expand Down Expand Up @@ -33,6 +32,8 @@ export function unhashedProjectId(remoteUrl: string, projectRootPath: string) {
}

let anonymousProjectId: string;
let getProjectSinceResult: Date | undefined;

export const getAnonymousProjectId = () => {
if (anonymousProjectId) {
return anonymousProjectId;
Expand All @@ -41,15 +42,45 @@ export const getAnonymousProjectId = () => {
try {
const projectRootPath = relative(getProjectRoot(), process.cwd());

const originBuffer = execSync(`git config --local --get remote.origin.url`, {
const result = executeCommandSync({
command: 'git',
args: ['config', '--get', 'remote.origin.url'],
timeout: 1000,
stdio: `pipe`,
});

anonymousProjectId = oneWayHash(unhashedProjectId(String(originBuffer), projectRootPath));
anonymousProjectId = oneWayHash(unhashedProjectId(result, projectRootPath));
} catch (_) {
//
}

return anonymousProjectId;
};

export const getProjectSince = () => {
try {
if (getProjectSinceResult) {
return getProjectSinceResult;
}

const dateBuffer = executeCommandSync({
command: 'git',
args: ['log', '--reverse', '--format=%cd', '--date=iso'],
timeout: 1000,
});

const firstLine = String(dateBuffer).trim().split('\n')[0];

const date = new Date(firstLine);

if (Number.isNaN(date.getTime())) {
return undefined;
}

getProjectSinceResult = date;
return date;
} catch (_) {
//
}

return undefined;
};
Comment thread
valentinpalkovic marked this conversation as resolved.
3 changes: 2 additions & 1 deletion code/core/src/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { nanoid } from 'nanoid';

import { version } from '../../package.json';
import { resolvePackageDir } from '../shared/utils/module';
import { getAnonymousProjectId } from './anonymous-id';
import { getAnonymousProjectId, getProjectSince } from './anonymous-id';
import { detectAgent } from './detect-agent';
import { set as saveToCache } from './event-cache';
import { fetch } from './fetch';
Expand Down Expand Up @@ -107,6 +107,7 @@ export async function sendTelemetry(
: {
...globalContext,
anonymousId: getAnonymousProjectId(),
projectSince: getProjectSince()?.getTime(),
};

let request: any;
Expand Down
8 changes: 8 additions & 0 deletions docs/configure/telemetry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ Will generate the following output:
{
"anonymousId": "8bcfdfd5f9616a1923dd92adf89714331b2d18693c722e05152a47f8093392bb",
"eventType": "dev",
"context": {
"isTTY": true,
"platform": "macOS",
"nodeVersion": "24.11.0",
"storybookVersion": "10.3.0-alpha.9",
"cliVersion": "10.3.0-alpha.9",
"projectSince": 1717334400000
},
"payload": {
"versionStatus": "cached",
"storyIndex": {
Expand Down
Loading