Skip to content

Commit c21958e

Browse files
authored
feat(posthog): per-actor distinctId via clientDeviceId / twitterHandle ladder (#218)
1 parent 541d241 commit c21958e

7 files changed

Lines changed: 280 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- AlterTable
2+
-- VARCHAR(128) mirrors the zod cap on the API body so the DB enforces
3+
-- the same contract for any non-handler write path (admin, seed, etc.).
4+
ALTER TABLE "AgentTemplateGeneration"
5+
ADD COLUMN "clientDeviceId" VARCHAR(128);

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ model AgentTemplateGeneration {
163163
idempotencyKey String
164164
inputs Json
165165
twitterContext Json?
166+
clientDeviceId String? @db.VarChar(128)
166167
templateId String? @db.Uuid
167168
publishStatus PublishStatus @default(draft)
168169
reply String? @db.Text

src/api/v2/agent-templates/handlers/generations-post.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,14 @@ const bodySchema = z
162162
source: z.string().min(1, "source is required"),
163163
inputs: inputsSchema,
164164
twitterContext: twitterContextSchema.optional(),
165+
/** Stable per-client identifier used as the PostHog distinctId fallback
166+
* when the request isn't authenticated (anonymous web/iOS, or the
167+
* twitter-bot path before we wire Twitter user IDs). Web should pass
168+
* posthog-js's `$device_id`. Capped at 128 chars to bound storage and
169+
* prevent abuse — posthog-js generates a UUIDv7 (~36 chars). Excluded
170+
* from the idempotency dedupe body comparison (`dedupeBodiesMatch`)
171+
* so a retry with a rotated device ID still matches the original row. */
172+
clientDeviceId: z.string().trim().min(1).max(128).optional(),
165173
publishStatus: z
166174
.enum(["draft", "unlisted", "published"])
167175
.optional()
@@ -714,6 +722,7 @@ export async function generationsPostHandler(req: Request, res: Response) {
714722
twitterContext: body.twitterContext
715723
? (body.twitterContext as Prisma.InputJsonValue)
716724
: Prisma.JsonNull,
725+
clientDeviceId: body.clientDeviceId ?? null,
717726
publishStatus: body.publishStatus,
718727
status: "pending",
719728
},

src/api/v2/agent-templates/services/generation-executor.ts

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,17 @@ import {
2828
buildDeterministicFallback,
2929
composeReply,
3030
} from "@/api/v2/agent-templates/services/compose-reply";
31-
import { capturePostHog } from "@/api/v2/agent-templates/services/posthog";
31+
import {
32+
capturePostHog,
33+
type PostHogCaptureProperties,
34+
} from "@/api/v2/agent-templates/services/posthog";
3235
import {
3336
callGenerateTemplate,
3437
getModel,
3538
type GenerateTemplateInput,
3639
} from "@/api/v2/agent-templates/services/templateGen";
3740
import { GENERATION_EXECUTOR_TIMEOUT_MS, GENERATION_TTL_HOURS } from "@/config";
41+
import { ADMIN_ACCOUNT_ID } from "@/utils/constants";
3842
import logger from "@/utils/logger";
3943
import { prisma } from "@/utils/prisma";
4044
import { validateSlug } from "@/utils/reserved-slugs";
@@ -109,6 +113,65 @@ interface TwitterContext {
109113
idea?: string;
110114
}
111115

116+
// ---------------------------------------------------------------------------
117+
// PostHog actor-attribution fields — shared across every capture site
118+
// ---------------------------------------------------------------------------
119+
120+
/** Generation row shape consumed by `postHogBase` — the actor-attribution
121+
* inputs only. Declared as a structural subset of the Prisma row so this
122+
* helper is decoupled from the full schema. */
123+
interface PostHogActorSource {
124+
ownerAccountId: string;
125+
source: string;
126+
twitterContext: unknown;
127+
clientDeviceId: string | null;
128+
}
129+
130+
/**
131+
* Build the actor-attribution + always-on fields shared across every
132+
* PostHog capture site in this pipeline. Centralised so the four sites
133+
* (generate-fail, persist-fail, timeout-race-fail, success) stay in sync —
134+
* adding a new actor signal only needs one edit here.
135+
*
136+
* `isAnonymous` is derived from the admin-account sentinel: anonymous
137+
* submissions are owned by `ADMIN_ACCOUNT_ID` so the row has a valid
138+
* owner FK, but the value is a system identity, not a real user.
139+
* `resolveActor` in posthog.ts skips ownerAccountId when this is set.
140+
*/
141+
function postHogBase(args: {
142+
generation: PostHogActorSource;
143+
requestId: string;
144+
inputType: "text" | "pdfBase64" | "imageBase64";
145+
}): Pick<
146+
PostHogCaptureProperties,
147+
| "requestId"
148+
| "inputType"
149+
| "source"
150+
| "ownerAccountId"
151+
| "isAnonymous"
152+
| "clientDeviceId"
153+
| "twitterUserId"
154+
> {
155+
const { generation, requestId, inputType } = args;
156+
const twitterCtx = generation.twitterContext as TwitterContext | null;
157+
return {
158+
requestId,
159+
inputType,
160+
source: generation.source,
161+
ownerAccountId: generation.ownerAccountId,
162+
isAnonymous: generation.ownerAccountId === ADMIN_ACCOUNT_ID,
163+
clientDeviceId: generation.clientDeviceId ?? undefined,
164+
// Lowercase + strip optional leading `@` so handle casing/format drift
165+
// on the Twitter side doesn't fragment a single user across multiple
166+
// PostHog persons. We don't currently have a stable numeric user ID
167+
// from the twitter bot — once it's threaded through, replace this with
168+
// `twitter:<numericUserId>` for true rename-resilient attribution.
169+
twitterUserId: twitterCtx?.twitterHandle
170+
? twitterCtx.twitterHandle.toLowerCase().replace(/^@/, "")
171+
: undefined,
172+
};
173+
}
174+
112175
// ---------------------------------------------------------------------------
113176
// Input coalescing — same priority as today's generate-template handler
114177
// ---------------------------------------------------------------------------
@@ -395,10 +458,7 @@ async function _runPipeline(
395458
promptTokens: 0,
396459
completionTokens: 0,
397460
latencyMs: Math.round(performance.now() - startTime),
398-
requestId: generationId,
399-
source: generation.source,
400-
ownerAccountId: generation.ownerAccountId,
401-
inputType,
461+
...postHogBase({ generation, requestId: generationId, inputType }),
402462
outcome: "failed",
403463
});
404464
throw new Error(
@@ -428,10 +488,7 @@ async function _runPipeline(
428488
} catch (err) {
429489
capturePostHog({
430490
...templateResult.metrics,
431-
requestId: generationId,
432-
source: generation.source,
433-
ownerAccountId: generation.ownerAccountId,
434-
inputType,
491+
...postHogBase({ generation, requestId: generationId, inputType }),
435492
outcome: "failed",
436493
});
437494
throw new Error(
@@ -501,21 +558,15 @@ async function _runPipeline(
501558
}
502559
capturePostHog({
503560
...templateResult.metrics,
504-
requestId: generationId,
505-
source: generation.source,
506-
ownerAccountId: generation.ownerAccountId,
507-
inputType,
561+
...postHogBase({ generation, requestId: generationId, inputType }),
508562
outcome: "failed",
509563
});
510564
return;
511565
}
512566

513567
capturePostHog({
514568
...templateResult.metrics,
515-
requestId: generationId,
516-
source: generation.source,
517-
ownerAccountId: generation.ownerAccountId,
518-
inputType,
569+
...postHogBase({ generation, requestId: generationId, inputType }),
519570
outcome: "done",
520571
});
521572
logger.info(

src/api/v2/agent-templates/services/posthog.ts

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,21 @@ export interface PostHogCaptureProperties extends GenerationMetrics {
4141
/** Source field from the AgentTemplateGeneration row — free-form
4242
* client telemetry tag (e.g. "ios-app", "web", "twitter-bot"). */
4343
source?: string;
44-
/** Account ID of the generation owner. */
44+
/** Account ID of the generation owner. Present whether or not the row
45+
* was anonymous — anonymous rows are owned by the admin sentinel. Use
46+
* `isAnonymous` to disambiguate. */
4547
ownerAccountId?: string;
48+
/** True when the row is owned by the admin sentinel (no real account).
49+
* When true, `ownerAccountId` is NOT used for actor attribution. */
50+
isAnonymous?: boolean;
51+
/** Twitter user identifier (handle, lowercased, '@' stripped) when the
52+
* generation was triggered via the twitter bot. Used as a fallback
53+
* actor identifier when there's no `ownerAccountId`. */
54+
twitterUserId?: string;
55+
/** Stable device identifier supplied by the client (e.g. posthog-js's
56+
* `$device_id` cookie). Used as a fallback actor identifier when the
57+
* user hasn't authenticated. */
58+
clientDeviceId?: string;
4659
/** Input type used for generation: "text", "pdfBase64", or "imageBase64". */
4760
inputType?: string;
4861
/** Terminal outcome: "done" or "failed". */
@@ -51,6 +64,63 @@ export interface PostHogCaptureProperties extends GenerationMetrics {
5164
authMode?: "jwt" | "agentKey";
5265
}
5366

67+
// ---------------------------------------------------------------------------
68+
// Actor attribution — the distinctId precedence ladder
69+
// ---------------------------------------------------------------------------
70+
71+
/**
72+
* Kind of identifier used as `distinctId` on the event. Sent as a top-level
73+
* `actorKind` property so downstream queries can segment by attribution type
74+
* without parsing the distinctId string. Survives prefix-convention changes;
75+
* makes post-merge analytics on aliased persons trivial.
76+
*/
77+
export type ActorKind = "account" | "device" | "twitter" | "unattributed";
78+
79+
export interface ResolvedActor {
80+
distinctId: string;
81+
kind: ActorKind;
82+
}
83+
84+
/**
85+
* Resolve the distinctId + kind to send with the event from the available
86+
* signals. Precedence:
87+
*
88+
* 1. `<ownerAccountId>` (real account) — kind: `account` — most stable.
89+
* 2. `device:<clientDeviceId>` — kind: `device` — anonymous web/iOS.
90+
* 3. `twitter:<twitterUserId>` — kind: `twitter` — anonymous twitter-bot.
91+
* 4. `request:<requestId>` — kind: `unattributed` — one-off fallback.
92+
*
93+
* Each non-account rung is namespaced (`device:`, `twitter:`, `request:`) so an
94+
* anonymous identifier never collides with a real account UUID — which is the
95+
* load-bearing property for safe `posthog.alias()` merges later: when an
96+
* anonymous user authenticates, the frontend can alias `device:<X>` into the
97+
* account UUID without risking a cross-person merge.
98+
*
99+
* Reasoning: anonymous web/twitter submissions still populate `ownerAccountId`
100+
* — the route uses `ADMIN_ACCOUNT_ID` as the system-identity fallback so the
101+
* row has a valid owner FK. Using that sentinel directly as distinctId would
102+
* collapse every anonymous submission onto a single PostHog person and defeat
103+
* the point of analytics. The executor sets `isAnonymous: true` when the
104+
* owner is the sentinel so this function skips to the next rung.
105+
*
106+
* `kind: "unattributed"` is the honest name for rung 4 — every event in this
107+
* file is a generation, so calling rung 4 "generation" would conflate event
108+
* type with actor identity. `unattributed` says what's actually true: we
109+
* have no stable actor signal and each event gets a one-off person.
110+
*/
111+
export function resolveActor(p: PostHogCaptureProperties): ResolvedActor {
112+
if (p.ownerAccountId && !p.isAnonymous) {
113+
return { distinctId: p.ownerAccountId, kind: "account" };
114+
}
115+
if (p.clientDeviceId) {
116+
return { distinctId: `device:${p.clientDeviceId}`, kind: "device" };
117+
}
118+
if (p.twitterUserId) {
119+
return { distinctId: `twitter:${p.twitterUserId}`, kind: "twitter" };
120+
}
121+
return { distinctId: `request:${p.requestId}`, kind: "unattributed" };
122+
}
123+
54124
// ---------------------------------------------------------------------------
55125
// Lazy PostHog client singleton
56126
// ---------------------------------------------------------------------------
@@ -134,10 +204,13 @@ export function capturePostHog(properties: PostHogCaptureProperties): void {
134204
const client = getPostHogClient();
135205
if (!client) return; // silent no-op when env not set
136206

207+
const actor = resolveActor(properties);
137208
client.capture({
138-
distinctId: "builder",
209+
distinctId: actor.distinctId,
139210
event: BUILDER_GENERATION_COMPLETED_EVENT,
140-
properties,
211+
// Carry the rung as a property so dashboards can segment by
212+
// attribution type without parsing prefixes off `distinct_id`.
213+
properties: { ...properties, actorKind: actor.kind },
141214
});
142215
} catch (err) {
143216
logger.error(

tests/agent-templates.executor.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,10 @@ describe("generation-executor", () => {
390390
expect(event.requestId).toBe(gen.id);
391391
expect(event.source).toBe(TEST_SOURCE);
392392
expect(event.ownerAccountId).toBe(ADMIN_ACCOUNT_ID);
393+
// Rows owned by ADMIN_ACCOUNT_ID are flagged anonymous so
394+
// resolveActor in posthog.ts skips the sentinel and falls
395+
// through to the next rung of the actor-attribution ladder.
396+
expect(event.isAnonymous).toBe(true);
393397
expect(event.inputType).toBe("text");
394398
expect(event.outcome).toBe("done");
395399
expect(event.model).toBe(DEFAULT_TEST_METRICS.model);

0 commit comments

Comments
 (0)