diff --git a/code/core/src/telemetry/anonymous-id.test.ts b/code/core/src/telemetry/anonymous-id.test.ts index 8277ca547e85..cdee088797ab 100644 --- a/code/core/src/telemetry/anonymous-id.test.ts +++ b/code/core/src/telemetry/anonymous-id.test.ts @@ -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://', () => { @@ -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(); + }); +}); + +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(); + }); +}); diff --git a/code/core/src/telemetry/anonymous-id.ts b/code/core/src/telemetry/anonymous-id.ts index 951a268a6263..a66fb88f0538 100644 --- a/code/core/src/telemetry/anonymous-id.ts +++ b/code/core/src/telemetry/anonymous-id.ts @@ -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'; @@ -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; @@ -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; +}; diff --git a/code/core/src/telemetry/telemetry.ts b/code/core/src/telemetry/telemetry.ts index b84ae26b47ac..cb5b903cd085 100644 --- a/code/core/src/telemetry/telemetry.ts +++ b/code/core/src/telemetry/telemetry.ts @@ -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'; @@ -107,6 +107,7 @@ export async function sendTelemetry( : { ...globalContext, anonymousId: getAnonymousProjectId(), + projectSince: getProjectSince()?.getTime(), }; let request: any; diff --git a/docs/configure/telemetry.mdx b/docs/configure/telemetry.mdx index 2d25e6d7ddeb..4aacc45029e7 100644 --- a/docs/configure/telemetry.mdx +++ b/docs/configure/telemetry.mdx @@ -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": {