Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
12e09ed
wip(telemetry): design doc + feature extraction spike
Sidnioulz Apr 7, 2026
3155701
feat(telemetry): add ai-setup-evidence event type
Sidnioulz Apr 8, 2026
2f2e6a9
feat(telemetry): preserve userSince for agents in CI
Sidnioulz Apr 8, 2026
559a09a
feat: add mock telemetry receiver for local debugging
Sidnioulz Apr 8, 2026
1fea584
refactor(ghost-stories): move trigger from modal to 10min delay, simp…
Sidnioulz Apr 8, 2026
1cb3610
feat(ai): thread traits accumulator through prompt generation
Sidnioulz Apr 8, 2026
5f11032
feat(ai): add telemetry, baseline snapshot, and frontmatter to sb ai …
Sidnioulz Apr 8, 2026
241710a
feat(telemetry): add evidence-based ai-setup completion tracking at C…
Sidnioulz Apr 8, 2026
cc92a8c
fix: update tests for ghost-stories, withTelemetry, and detect-agent …
Sidnioulz Apr 8, 2026
ec41d25
Apply suggestion from @Sidnioulz
Sidnioulz Apr 9, 2026
a6f4f1f
Apply suggestion from @Sidnioulz
Sidnioulz Apr 9, 2026
0155a76
Apply suggestion from @Sidnioulz
Sidnioulz Apr 9, 2026
eec68c2
Apply suggestion from @Sidnioulz
Sidnioulz Apr 9, 2026
b571cf5
Apply suggestion from @Sidnioulz
Sidnioulz Apr 9, 2026
090c511
refactor: rename ai-setup-evidence to ai-prepare-evidence, add ai-pre…
Sidnioulz Apr 10, 2026
fec53a5
feat: create ai-prepare-evidence module with evidence collection, sto…
Sidnioulz Apr 10, 2026
8606a4a
refactor: replace inline AiSetupPendingRecord with type import from a…
Sidnioulz Apr 10, 2026
a529bcb
refactor: move AiSetupPendingRecord and isStoryCreatedByAISetup to te…
Sidnioulz Apr 10, 2026
d22240d
refactor: remove evidence collection from withTelemetry, import from …
Sidnioulz Apr 10, 2026
f60ecfe
feat: fire ai-prepare evidence from doTelemetry with story index for …
Sidnioulz Apr 10, 2026
1826b68
test: add unit tests for ai-prepare-evidence module
Sidnioulz Apr 10, 2026
a6a2532
chore: clean up dead detect-agent mock, fix test name for agent detec…
Sidnioulz Apr 10, 2026
82f7bcd
feat: add AI story scoring to ghost stories channel with CPU capacity…
Sidnioulz Apr 10, 2026
8c9eaa0
Fix linting
Sidnioulz Apr 10, 2026
c891c83
Update implementation
Sidnioulz Apr 11, 2026
b9bb015
Update test mocks and fixtures
Sidnioulz Apr 11, 2026
50ddc1f
Remove spike script for feature adoption data flow
Sidnioulz Apr 11, 2026
f802462
Align type imports between two files
Sidnioulz Apr 11, 2026
f351600
Flush event cache when ai prepare event is expired
Sidnioulz Apr 11, 2026
9e795d3
Apply suggestion from @Sidnioulz
Sidnioulz Apr 14, 2026
f00c02f
Apply suggestion from @Sidnioulz
Sidnioulz Apr 14, 2026
1687113
Merge branch 'project/sb-agentic-setup' into sidnioulz/agentic-teleme…
Sidnioulz Apr 14, 2026
956d2fe
Merge telemetry loggers
Sidnioulz Apr 14, 2026
8119825
Switch delayed analytics to a 4m timeout
Sidnioulz Apr 14, 2026
f51a483
Fix timings in tests
Sidnioulz Apr 14, 2026
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
7 changes: 7 additions & 0 deletions code/core/src/builder-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { cp, rm, writeFile } from 'node:fs/promises';

import { stringifyProcessEnvs } from 'storybook/internal/common';
import { logger } from 'storybook/internal/node-logger';
import { getLastEvents, getSessionId } from 'storybook/internal/telemetry';

import { globalExternals } from '@fal-works/esbuild-plugin-global-externals';
import { resolveModulePath } from 'exsolve';
Expand Down Expand Up @@ -199,6 +200,9 @@ const starter: StarterFunction = async function* starterGeneratorFn({

// Build additional global values
const globals: Record<string, any> = await buildFrameworkGlobalsFromOptions(options);
// Pass along recent CLI events to customise user onboarding and telemetry after `sb ai` commands.
globals.STORYBOOK_LAST_EVENTS = await getLastEvents();
globals.STORYBOOK_SESSION_ID = await getSessionId();

yield;

Expand Down Expand Up @@ -297,6 +301,9 @@ const builder: BuilderFunction = async function* builderGeneratorFn({ startTime,

// Build additional global values
const globals: Record<string, any> = await buildFrameworkGlobalsFromOptions(options);
// Builds are long-lasting and shouldn't account for CLI events prior to building.
globals.STORYBOOK_LAST_EVENTS = {};
globals.STORYBOOK_SESSION_ID = undefined;

yield;

Expand Down
1 change: 1 addition & 0 deletions code/core/src/common/satellite-addons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default [
'@storybook/addon-coverage',
'@storybook/addon-webpack5-compiler-babel',
'@storybook/addon-webpack5-compiler-swc',
'@storybook/addon-mcp',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This was caught up during my review of the existing telemetry, we're not putting this addon in our own satellites bucket.

// Storybook for React Native related packages
// TODO: For Storybook 10, we should check about possible automigrations
'@storybook/addon-ondevice-actions',
Expand Down
5 changes: 5 additions & 0 deletions code/core/src/core-events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ enum events {
// Story discovery and testing flow
GHOST_STORIES_REQUEST = 'ghostStoriesRequest',
GHOST_STORIES_RESPONSE = 'ghostStoriesResponse',
// AI analytics - ai prepare command
AI_PREPARE_ANALYTICS_RESPONSE = 'aiPrepareAnalyticsResponse',
AI_PREPARE_ANALYTICS_REQUEST = 'aiPrepareAnalyticsRequest',
// Open a file in the code editor
OPEN_IN_EDITOR_REQUEST = 'openInEditorRequest',
OPEN_IN_EDITOR_RESPONSE = 'openInEditorResponse',
Expand Down Expand Up @@ -168,6 +171,8 @@ export const {
ARGTYPES_INFO_RESPONSE,
GHOST_STORIES_REQUEST,
GHOST_STORIES_RESPONSE,
AI_PREPARE_ANALYTICS_RESPONSE,
AI_PREPARE_ANALYTICS_REQUEST,
OPEN_IN_EDITOR_REQUEST,
OPEN_IN_EDITOR_RESPONSE,
MANAGER_INERT_ATTRIBUTE_CHANGED,
Expand Down
2 changes: 2 additions & 0 deletions code/core/src/core-server/presets/common-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { initializeSaveStory } from '../utils/save-story/save-story.ts';
import { parseStaticDir } from '../utils/server-statics.ts';
import { type OptionsWithRequiredCache, initializeWhatsNew } from '../utils/whats-new.ts';
import { getWsToken } from './wsToken.ts';
import { initAIAnalyticsChannel } from '../server-channel/ai-prepare-channel.ts';

const interpolate = (string: string, data: Record<string, string> = {}) =>
Object.entries(data).reduce((acc, [k, v]) => acc.replace(new RegExp(`%${k}%`, 'g'), v), string);
Expand Down Expand Up @@ -278,6 +279,7 @@ export const experimental_serverChannel = async (
initializeWhatsNew(channel, options, coreOptions);
initializeSaveStory(channel, options, coreOptions);

initAIAnalyticsChannel(channel, options, coreOptions, () => storyIndexGeneratorPromise);
initFileSearchChannel(channel, options, coreOptions);
initCreateNewStoryChannel(channel, options, coreOptions);
initGhostStoriesChannel(channel, options, coreOptions);
Expand Down
131 changes: 131 additions & 0 deletions code/core/src/core-server/server-channel/ai-prepare-channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { Channel } from 'storybook/internal/channels';
import {
AI_PREPARE_ANALYTICS_REQUEST,
AI_PREPARE_ANALYTICS_RESPONSE,
} from 'storybook/internal/core-events';
import {
getLastEvents,
getStorybookMetadata,
isStoryCreatedByAIPrepare,
telemetry,
} from 'storybook/internal/telemetry';
import type { CoreConfig, Options } from 'storybook/internal/types';
import { logger } from 'storybook/internal/node-logger';

import { runGhostStories } from '../utils/ghost-stories/run-story-tests.ts';
import type { StoryIndexGenerator } from 'storybook/internal/core-server';
import { waitForIdleVitest } from '../utils/wait-for-idle-vitest.ts';

export function initAIAnalyticsChannel(
channel: Channel,
options: Options,
coreOptions: CoreConfig,
getStoryIndexGeneratorPromise?: () => Promise<StoryIndexGenerator> | undefined
) {
if (coreOptions.disableTelemetry) {
return channel;
}

/** Send analytics about the ai prepare workflow when requested*/
channel.on(AI_PREPARE_ANALYTICS_REQUEST, async () => {
const stats: {
fileCount?: number;
storyCount?: number;
testRunDuration?: number;
} = {};

try {
const lastEvents = await getLastEvents();
const lastAIPrepare = lastEvents?.['ai-prepare'];
const lastPrepareStoryScoringRun = lastEvents?.['ai-prepare-story-scoring'];

// Only run if sb ai prepare has been called
if (!lastAIPrepare) {
return;
}

// Already ran once for this project — never run again
if (lastPrepareStoryScoringRun) {
return;
}

const metadata = await getStorybookMetadata(options.configDir);
const isReactStorybook = metadata?.renderer?.includes('@storybook/react');
const hasVitestAddon =
!!metadata?.addons &&
Object.keys(metadata.addons).some((addonKey) =>
addonKey.includes('@storybook/addon-vitest')
);

// For now this is gated by React + Vitest
if (!isReactStorybook || !hasVitestAddon) {
return;
}

// Wait for any running tests to finish before launching scoring, so we don't
// disturb end user activities.
const isIdle = await waitForIdleVitest();
if (!isIdle) {
logger.debug('AI_PREPARE_ANALYTICS_REQUEST timed out waiting for vitest to be available.');
return;
}

// Fetch AI-generated stories and score them with the ghost stories metrics, if any are found.
const generatorPromise = getStoryIndexGeneratorPromise?.();
if (!generatorPromise) {
logger.debug(
'AI_PREPARE_ANALYTICS_REQUEST could not proceed as the index generator is not ready.'
);
return;
}

const generator = await generatorPromise;
const indexAndStats = await generator.getIndexAndStats();
if (!indexAndStats) {
logger.debug('AI_PREPARE_ANALYTICS_REQUEST could not proceed as the index is not ready.');
return;
}

const aiStoryFiles = new Set<string>();
let aiStoryCount = 0;
for (const entry of Object.values(indexAndStats.storyIndex.entries)) {
if (isStoryCreatedByAIPrepare(entry)) {
aiStoryFiles.add(entry.importPath);
aiStoryCount++;
}
}

if (aiStoryFiles.size > 0) {
const aiTestRunResult = await runGhostStories([...aiStoryFiles]);
telemetry('ai-prepare-story-scoring', {
stats: {
fileCount: aiStoryFiles.size,
storyCount: aiStoryCount,
testRunDuration: aiTestRunResult.duration,
},
results: aiTestRunResult.summary,
...(aiTestRunResult.runError ? { runError: aiTestRunResult.runError } : {}),
});
} else {
telemetry('ai-prepare-story-scoring', {
stats: {
fileCount: 0,
storyCount: 0,
testRunDuration: 0,
},
runError: 'No stories found that were generated by ai setup',
});
}
} catch {
telemetry('ai-prepare-story-scoring', {
stats,
runError: 'Unknown error during AI story scoring',
});
} finally {
// we don't currently do anything with this, but will be useful in the future
channel.emit(AI_PREPARE_ANALYTICS_RESPONSE);
}
});

return channel;
}
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ describe('ghostStoriesChannel', () => {
});

expect(mockTelemetry.getLastEvents).toHaveBeenCalled();
expect(mockTelemetry.getSessionId).toHaveBeenCalled();
expect(mockTelemetry.getSessionId).not.toHaveBeenCalled();
expect(mockTelemetry.getStorybookMetadata).not.toHaveBeenCalled();
expect(mockStoryGeneration.getComponentCandidates).not.toHaveBeenCalled();
});
Expand Down
29 changes: 23 additions & 6 deletions code/core/src/core-server/server-channel/ghost-stories-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
getStorybookMetadata,
telemetry,
} from 'storybook/internal/telemetry';
import { logger } from 'storybook/internal/node-logger';
import type { CoreConfig, Options } from 'storybook/internal/types';

import { getComponentCandidates } from '../utils/ghost-stories/get-candidates.ts';
import { runGhostStories } from '../utils/ghost-stories/run-story-tests.ts';
import { waitForIdleVitest } from '../utils/wait-for-idle-vitest.ts';

export function initGhostStoriesChannel(
channel: Channel,
Expand All @@ -36,16 +38,23 @@ export function initGhostStoriesChannel(
const ghostRunStart = Date.now();
const lastEvents = await getLastEvents();
const lastInit = lastEvents?.init;
if (!lastEvents || !lastInit) {
const lastAIPrepare = lastEvents?.['ai-prepare'];
const lastGhostStoriesRun = lastEvents?.['ghost-stories'];

// We only want to run ghost stories immediately after init or ai prepare.
const lastRelevantEvent = lastAIPrepare ?? lastInit;
if (!lastRelevantEvent) {
return;
}

// Already ran once for this project — never run again
if (lastGhostStoriesRun) {
return;
}

const sessionId = await getSessionId();
const lastGhostStoriesRun = lastEvents['ghost-stories'];
if (
lastGhostStoriesRun ||
(lastInit.body?.sessionId && lastInit.body.sessionId !== sessionId)
) {
// We only capture ghost stories in the first ever session since init or ai prepare.
if (lastRelevantEvent.body?.sessionId && lastRelevantEvent.body.sessionId !== sessionId) {
return;
}

Expand All @@ -62,6 +71,14 @@ export function initGhostStoriesChannel(
return;
}

// Wait for any running tests to finish before launching scoring, so we don't
// disturb end user activities.
const isIdle = await waitForIdleVitest();
if (!isIdle) {
logger.debug('GHOST_STORIES_REQUEST timed out waiting for vitest to be available.');
return;
}

// Phase 1: find candidates from components
const candidateAnalysisStart = Date.now();
const candidatesResult = await getComponentCandidates();
Expand Down
14 changes: 13 additions & 1 deletion code/core/src/core-server/utils/doTelemetry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { getPrecedingUpgrade, telemetry } from 'storybook/internal/telemetry';
import {
collectAiPrepareEvidence,
getPrecedingUpgrade,
telemetry,
} from 'storybook/internal/telemetry';
import type { CoreConfig, Options } from 'storybook/internal/types';

import type { Polka } from 'polka';
Expand Down Expand Up @@ -36,6 +40,14 @@ export async function doTelemetry(
});
return;
}

// sb ai commands trigger side effects performed by agent harnesses, which can't be observed
// directly. This is the entry point for collecting evidence about those side effects and
// recording them in telemetry.
if (indexAndStats) {
collectAiPrepareEvidence('dev', options.configDir, indexAndStats.storyIndex);
}

const { versionCheck, versionUpdates } = options;
invariant(
!versionUpdates || (versionUpdates && versionCheck),
Expand Down
Loading
Loading