Skip to content

Commit 9b2d0fa

Browse files
committed
fix(orchestration): hash telemetry identifiers before analytics
- await hashed workspace and thread identifiers before recording events - add regression coverage for project and thread analytics properties
1 parent d3a1a38 commit 9b2d0fa

2 files changed

Lines changed: 103 additions & 3 deletions

File tree

apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
type OrchestrationEventStoreShape,
2121
} from "../../persistence/Services/OrchestrationEventStore.ts";
2222
import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
23+
import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
24+
import { hashTelemetryIdentifier } from "../../telemetry/Identify.ts";
2325
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2426
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
2527
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -38,6 +40,10 @@ const asTurnId = (value: string): TurnId => TurnId.make(value);
3840
const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
3941

4042
async 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;

apps/server/src/orchestration/Layers/OrchestrationEngine.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,19 @@ const makeOrchestrationEngine = Effect.gen(function* () {
101101
switch (event.type) {
102102
case "project.created": {
103103
const projectName = event.payload.title.trim();
104+
const workspaceRootHash = yield* hashTelemetryIdentifier(event.payload.workspaceRoot);
104105
yield* recordAnalytics("marcode.project.opened", {
105106
"project.name": projectName,
106-
"project.cwd_hash": hashTelemetryIdentifier(event.payload.workspaceRoot),
107+
"project.cwd_hash": workspaceRootHash,
107108
});
108109
return;
109110
}
110111

111112
case "thread.created": {
112113
const project = readModel.projects.find((entry) => entry.id === event.payload.projectId);
114+
const threadIdHash = yield* hashTelemetryIdentifier(event.payload.threadId);
113115
yield* recordAnalytics("marcode.thread.created", {
114-
"thread.id_hash": hashTelemetryIdentifier(event.payload.threadId),
116+
"thread.id_hash": threadIdHash,
115117
...(project ? { "project.name": project.title } : {}),
116118
"provider.default": event.payload.modelSelection.provider,
117119
});
@@ -125,8 +127,9 @@ const makeOrchestrationEngine = Effect.gen(function* () {
125127
? readModel.projects.find((entry) => entry.id === thread.projectId)
126128
: undefined;
127129
const modelSelection = thread?.modelSelection;
130+
const threadIdHash = yield* hashTelemetryIdentifier(event.payload.threadId);
128131
yield* recordAnalytics("marcode.message.user.sent", {
129-
"thread.id_hash": hashTelemetryIdentifier(event.payload.threadId),
132+
"thread.id_hash": threadIdHash,
130133
...(project ? { "project.name": project.title } : {}),
131134
...(modelSelection
132135
? {

0 commit comments

Comments
 (0)