@@ -20,6 +20,8 @@ import {
2020 type OrchestrationEventStoreShape ,
2121} from "../../persistence/Services/OrchestrationEventStore.ts" ;
2222import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts" ;
23+ import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts" ;
24+ import { hashTelemetryIdentifier } from "../../telemetry/Identify.ts" ;
2325import { OrchestrationEngineLive } from "./OrchestrationEngine.ts" ;
2426import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts" ;
2527import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts" ;
@@ -38,6 +40,10 @@ const asTurnId = (value: string): TurnId => TurnId.make(value);
3840const asCheckpointRef = ( value : string ) : CheckpointRef => CheckpointRef . make ( value ) ;
3941
4042async function createOrchestrationSystem ( ) {
43+ const analyticsRecords : Array < {
44+ readonly event : string ;
45+ readonly properties ?: Readonly < Record < string , unknown > > ;
46+ } > = [ ] ;
4147 const ServerConfigLayer = ServerConfig . layerTest ( process . cwd ( ) , {
4248 prefix : "marcode-orchestration-engine-test-" ,
4349 } ) ;
@@ -48,13 +54,26 @@ async function createOrchestrationSystem() {
4854 Layer . provide ( OrchestrationCommandReceiptRepositoryLive ) ,
4955 Layer . provide ( RepositoryIdentityResolverLive ) ,
5056 Layer . provide ( SqlitePersistenceMemory ) ,
57+ Layer . provide (
58+ Layer . succeed ( AnalyticsService , {
59+ record : ( event , properties ) =>
60+ Effect . sync ( ( ) => {
61+ analyticsRecords . push ( {
62+ event,
63+ ...( properties ? { properties } : { } ) ,
64+ } ) ;
65+ } ) ,
66+ flush : Effect . void ,
67+ } ) ,
68+ ) ,
5169 Layer . provideMerge ( ServerConfigLayer ) ,
5270 Layer . provideMerge ( NodeServices . layer ) ,
5371 ) ;
5472 const runtime = ManagedRuntime . make ( orchestrationLayer ) ;
5573 const engine = await runtime . runPromise ( Effect . service ( OrchestrationEngineService ) ) ;
5674 return {
5775 engine,
76+ analyticsRecords,
5877 run : < A , E > ( effect : Effect . Effect < A , E > ) => runtime . runPromise ( effect ) ,
5978 dispose : ( ) => runtime . dispose ( ) ,
6079 } ;
@@ -432,6 +451,84 @@ describe("OrchestrationEngine", () => {
432451 await system . dispose ( ) ;
433452 } ) ;
434453
454+ it ( "records resolved analytics hashes for project and thread events" , async ( ) => {
455+ const system = await createOrchestrationSystem ( ) ;
456+ const { engine, analyticsRecords } = system ;
457+ const createdAt = now ( ) ;
458+
459+ await system . run (
460+ engine . dispatch ( {
461+ type : "project.create" ,
462+ commandId : CommandId . make ( "cmd-project-analytics-create" ) ,
463+ projectId : asProjectId ( "project-analytics" ) ,
464+ title : "Analytics Project" ,
465+ workspaceRoot : "/tmp/project-analytics" ,
466+ defaultModelSelection : {
467+ provider : "codex" ,
468+ model : "gpt-5-codex" ,
469+ } ,
470+ createdAt,
471+ } ) ,
472+ ) ;
473+ await system . run (
474+ engine . dispatch ( {
475+ type : "thread.create" ,
476+ commandId : CommandId . make ( "cmd-thread-analytics-create" ) ,
477+ threadId : ThreadId . make ( "thread-analytics" ) ,
478+ projectId : asProjectId ( "project-analytics" ) ,
479+ title : "analytics" ,
480+ modelSelection : {
481+ provider : "codex" ,
482+ model : "gpt-5-codex" ,
483+ } ,
484+ interactionMode : DEFAULT_PROVIDER_INTERACTION_MODE ,
485+ runtimeMode : "approval-required" ,
486+ branch : null ,
487+ worktreePath : null ,
488+ createdAt,
489+ } ) ,
490+ ) ;
491+ await system . run (
492+ engine . dispatch ( {
493+ type : "thread.turn.start" ,
494+ commandId : CommandId . make ( "cmd-turn-analytics-start" ) ,
495+ threadId : ThreadId . make ( "thread-analytics" ) ,
496+ message : {
497+ messageId : asMessageId ( "msg-analytics-1" ) ,
498+ role : "user" ,
499+ text : "hello" ,
500+ attachments : [ ] ,
501+ } ,
502+ interactionMode : DEFAULT_PROVIDER_INTERACTION_MODE ,
503+ runtimeMode : "approval-required" ,
504+ createdAt,
505+ } ) ,
506+ ) ;
507+
508+ const expectedWorkspaceHash = await system . run (
509+ hashTelemetryIdentifier ( "/tmp/project-analytics" ) ,
510+ ) ;
511+ const expectedThreadHash = await system . run ( hashTelemetryIdentifier ( "thread-analytics" ) ) ;
512+
513+ expect (
514+ analyticsRecords . find ( ( record ) => record . event === "marcode.project.opened" ) ?. properties ?. [
515+ "project.cwd_hash"
516+ ] ,
517+ ) . toBe ( expectedWorkspaceHash ) ;
518+ expect (
519+ analyticsRecords . find ( ( record ) => record . event === "marcode.thread.created" ) ?. properties ?. [
520+ "thread.id_hash"
521+ ] ,
522+ ) . toBe ( expectedThreadHash ) ;
523+ expect (
524+ analyticsRecords . find ( ( record ) => record . event === "marcode.message.user.sent" ) ?. properties ?. [
525+ "thread.id_hash"
526+ ] ,
527+ ) . toBe ( expectedThreadHash ) ;
528+
529+ await system . dispose ( ) ;
530+ } ) ;
531+
435532 it ( "records command ack duration using the first committed event type" , async ( ) => {
436533 const system = await createOrchestrationSystem ( ) ;
437534 const { engine } = system ;
0 commit comments