@hogsend/engine 0.17.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/container.ts +114 -21
- package/src/env.ts +15 -0
- package/src/index.ts +8 -1
- package/src/lib/analytics-adapter.ts +68 -0
- package/src/lib/analytics-provider-registry.ts +35 -0
- package/src/lib/analytics-providers-from-env.ts +36 -0
- package/src/lib/analytics-singleton.ts +2 -2
- package/src/lib/bucket-posthog-sync.ts +8 -6
- package/src/lib/contacts.ts +45 -7
- package/src/lib/ingestion.ts +11 -2
- package/src/lib/posthog.ts +16 -0
- package/src/routes/events/index.ts +5 -0
- package/src/worker.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,14 +40,14 @@
|
|
|
40
40
|
"svix": "^1.95.1",
|
|
41
41
|
"winston": "^3.19.0",
|
|
42
42
|
"zod": "^4.4.3",
|
|
43
|
-
"@hogsend/core": "^0.
|
|
44
|
-
"@hogsend/
|
|
45
|
-
"@hogsend/
|
|
46
|
-
"@hogsend/plugin-posthog": "^0.
|
|
47
|
-
"@hogsend/plugin-resend": "^0.
|
|
43
|
+
"@hogsend/core": "^0.19.0",
|
|
44
|
+
"@hogsend/email": "^0.19.0",
|
|
45
|
+
"@hogsend/db": "^0.19.0",
|
|
46
|
+
"@hogsend/plugin-posthog": "^0.19.0",
|
|
47
|
+
"@hogsend/plugin-resend": "^0.19.0"
|
|
48
48
|
},
|
|
49
49
|
"optionalDependencies": {
|
|
50
|
-
"@hogsend/plugin-postmark": "^0.
|
|
50
|
+
"@hogsend/plugin-postmark": "^0.19.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.15.3",
|
package/src/container.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AnalyticsProvider,
|
|
4
|
+
EmailProvider,
|
|
5
|
+
PostHogService,
|
|
6
|
+
TimeZone,
|
|
7
|
+
} from "@hogsend/core";
|
|
3
8
|
import type { BucketRegistry, JourneyRegistry } from "@hogsend/core/registry";
|
|
4
9
|
import type { SendWindow } from "@hogsend/core/schedule";
|
|
5
10
|
import {
|
|
@@ -25,6 +30,12 @@ import { env } from "./env.js";
|
|
|
25
30
|
import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
|
|
26
31
|
import type { DefinedJourney } from "./journeys/define-journey.js";
|
|
27
32
|
import { buildJourneyRegistry } from "./journeys/registry.js";
|
|
33
|
+
import {
|
|
34
|
+
isAnalyticsProvider,
|
|
35
|
+
wrapLegacyAnalyticsService,
|
|
36
|
+
} from "./lib/analytics-adapter.js";
|
|
37
|
+
import { AnalyticsProviderRegistry } from "./lib/analytics-provider-registry.js";
|
|
38
|
+
import { analyticsProvidersFromEnv } from "./lib/analytics-providers-from-env.js";
|
|
28
39
|
import { setAnalytics } from "./lib/analytics-singleton.js";
|
|
29
40
|
import { type Auth, createAuth } from "./lib/auth.js";
|
|
30
41
|
import {
|
|
@@ -41,7 +52,6 @@ import type {
|
|
|
41
52
|
import { hatchet } from "./lib/hatchet.js";
|
|
42
53
|
import { createLogger, type Logger } from "./lib/logger.js";
|
|
43
54
|
import { createTrackedMailer } from "./lib/mailer.js";
|
|
44
|
-
import { getPostHog } from "./lib/posthog.js";
|
|
45
55
|
import { createRedisSecondaryStorage, getRedis } from "./lib/redis.js";
|
|
46
56
|
import { sendResetPasswordEmail } from "./lib/reset-email.js";
|
|
47
57
|
import { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
|
|
@@ -92,7 +102,18 @@ export interface HogsendClient {
|
|
|
92
102
|
* templates without going through a send. Empty when no templates are wired.
|
|
93
103
|
*/
|
|
94
104
|
templates: TemplateRegistry;
|
|
95
|
-
|
|
105
|
+
/**
|
|
106
|
+
* The container-held registry of analytics providers, keyed by `meta.id` —
|
|
107
|
+
* the analytics sibling of {@link emailProviders}. Built from env presets
|
|
108
|
+
* (`analyticsProvidersFromEnv`) merged consumer-last.
|
|
109
|
+
*/
|
|
110
|
+
analyticsProviders: AnalyticsProviderRegistry;
|
|
111
|
+
/**
|
|
112
|
+
* The single resolved ACTIVE analytics provider (identity PULL + person
|
|
113
|
+
* writes + capture). Undefined when nothing is configured — every consumer
|
|
114
|
+
* treats that as a silent no-op.
|
|
115
|
+
*/
|
|
116
|
+
analytics?: AnalyticsProvider;
|
|
96
117
|
registry: JourneyRegistry;
|
|
97
118
|
/**
|
|
98
119
|
* The bucket registry (id map + event/property inverted indexes for candidate
|
|
@@ -171,25 +192,43 @@ export interface HogsendClientOptions {
|
|
|
171
192
|
templates?: TemplateRegistry;
|
|
172
193
|
};
|
|
173
194
|
/**
|
|
174
|
-
* The
|
|
195
|
+
* The analytics provider(s) — provider-neutral since the
|
|
196
|
+
* `AnalyticsProvider` contract (the analytics sibling of `EmailProvider`;
|
|
197
|
+
* PostHog is the reference implementation, not the architecture). Its role
|
|
175
198
|
* is deliberately NARROW — it is NOT the outbound-catalog firing path (the
|
|
176
|
-
* email/contact/journey/bucket lifecycle
|
|
177
|
-
*
|
|
178
|
-
* remains for exactly two things:
|
|
199
|
+
* email/contact/journey/bucket lifecycle fans out durably via DESTINATIONS
|
|
200
|
+
* on the webhook spine). The ACTIVE provider serves:
|
|
179
201
|
*
|
|
180
202
|
* 1. The identity PULL — `getPersonProperties` for per-user timezone
|
|
181
203
|
* resolution at journey enrollment (`define-journey` / `lib/timezone.ts`).
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
204
|
+
* On PostHog this needs `POSTHOG_PERSONAL_API_KEY` (the phc_ project key
|
|
205
|
+
* is write-only by design); reads soft-fail to contact-property
|
|
206
|
+
* fallbacks without it.
|
|
207
|
+
* 2. Person WRITES — `setPersonProperties` (the opt-in `bucket.syncToPostHog`
|
|
208
|
+
* mirror, and trait propagation). Rides the capture pipeline; no extra
|
|
209
|
+
* credential.
|
|
210
|
+
*
|
|
211
|
+
* Accepted shapes (mirrors `email`):
|
|
212
|
+
* - a group: `{ provider?, providers?, defaultProvider? }` — register one or
|
|
213
|
+
* many `AnalyticsProvider`s; env presets (`analyticsProvidersFromEnv` —
|
|
214
|
+
* PostHog when `POSTHOG_API_KEY` is set) merge consumer-LAST;
|
|
215
|
+
* `defaultProvider` / env `ANALYTICS_PROVIDER` picks the active id
|
|
216
|
+
* (default `"posthog"`).
|
|
217
|
+
* - a bare `AnalyticsProvider` — registered and made active.
|
|
218
|
+
* - @deprecated a legacy `PostHogService` — wrapped via
|
|
219
|
+
* `wrapLegacyAnalyticsService` and made active.
|
|
187
220
|
*
|
|
188
221
|
* Lives at the top level (not under `email`) because the engine itself uses
|
|
189
|
-
* it for the PULL.
|
|
190
|
-
* `POSTHOG_API_KEY` is unset).
|
|
222
|
+
* it for the PULL.
|
|
191
223
|
*/
|
|
192
|
-
analytics?:
|
|
224
|
+
analytics?:
|
|
225
|
+
| PostHogService
|
|
226
|
+
| AnalyticsProvider
|
|
227
|
+
| {
|
|
228
|
+
provider?: AnalyticsProvider;
|
|
229
|
+
providers?: AnalyticsProvider[];
|
|
230
|
+
defaultProvider?: string;
|
|
231
|
+
};
|
|
193
232
|
/**
|
|
194
233
|
* Code-defined outbound DESTINATIONS (Phase 3). Each is a
|
|
195
234
|
* `defineDestination()` delivery-time transform keyed by its `meta.id`, which
|
|
@@ -462,16 +501,69 @@ export function createHogsendClient(
|
|
|
462
501
|
},
|
|
463
502
|
});
|
|
464
503
|
|
|
465
|
-
|
|
504
|
+
// Resolve the analytics provider(s) — mirrors the email-provider shape:
|
|
505
|
+
// env presets first, consumer registrations LAST (last-writer-wins), then
|
|
506
|
+
// ONE active provider picked by id. The deprecated bare-PostHogService and
|
|
507
|
+
// bare-AnalyticsProvider forms register-and-activate directly.
|
|
508
|
+
const analyticsOpt = opts.analytics;
|
|
509
|
+
const analyticsGroup =
|
|
510
|
+
analyticsOpt &&
|
|
511
|
+
!isAnalyticsProvider(analyticsOpt as AnalyticsProvider) &&
|
|
512
|
+
typeof (analyticsOpt as PostHogService).captureEvent !== "function"
|
|
513
|
+
? (analyticsOpt as {
|
|
514
|
+
provider?: AnalyticsProvider;
|
|
515
|
+
providers?: AnalyticsProvider[];
|
|
516
|
+
defaultProvider?: string;
|
|
517
|
+
})
|
|
518
|
+
: undefined;
|
|
519
|
+
|
|
520
|
+
const analyticsProviders = new AnalyticsProviderRegistry([
|
|
521
|
+
...analyticsProvidersFromEnv(env),
|
|
522
|
+
...(analyticsGroup?.providers ?? []),
|
|
523
|
+
...(analyticsGroup?.provider ? [analyticsGroup.provider] : []),
|
|
524
|
+
]);
|
|
525
|
+
|
|
526
|
+
let analytics: AnalyticsProvider | undefined;
|
|
527
|
+
if (analyticsOpt && !analyticsGroup) {
|
|
528
|
+
// Bare provider or legacy service: register and activate it directly.
|
|
529
|
+
analytics = isAnalyticsProvider(analyticsOpt as AnalyticsProvider)
|
|
530
|
+
? (analyticsOpt as AnalyticsProvider)
|
|
531
|
+
: wrapLegacyAnalyticsService(analyticsOpt as PostHogService);
|
|
532
|
+
analyticsProviders.register(analytics);
|
|
533
|
+
} else {
|
|
534
|
+
const activeId = analyticsGroup?.defaultProvider ?? env.ANALYTICS_PROVIDER;
|
|
535
|
+
analytics = analyticsProviders.get(activeId);
|
|
536
|
+
if (analyticsGroup?.defaultProvider && !analytics) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`analytics.defaultProvider "${analyticsGroup.defaultProvider}" is not a registered analytics provider (registered: ${analyticsProviders
|
|
539
|
+
.getAll()
|
|
540
|
+
.map((p) => p.meta.id)
|
|
541
|
+
.join(", ")})`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Person reads need a privileged credential on most platforms (PostHog: a
|
|
547
|
+
// personal API key — the phc_ project key is write-only by design). Surface
|
|
548
|
+
// the degraded mode once at boot instead of letting tz resolution silently
|
|
549
|
+
// fall back for months.
|
|
550
|
+
if (analytics && !analytics.capabilities.personReads) {
|
|
551
|
+
logger.info(
|
|
552
|
+
`analytics provider "${analytics.meta.id}" has person reads DISABLED — ` +
|
|
553
|
+
"timezone resolution falls back to contact properties. For PostHog, " +
|
|
554
|
+
"set POSTHOG_PERSONAL_API_KEY (a personal API key scoped person:read). " +
|
|
555
|
+
"Docs: https://hogsend.com/docs/guides/analytics-access",
|
|
556
|
+
);
|
|
557
|
+
}
|
|
466
558
|
|
|
467
559
|
// Expose the resolved analytics instance to the module-level task-execution
|
|
468
560
|
// sites that have no client reference. Its role is NARROW (see the
|
|
469
561
|
// `analytics?` option doc): the identity PULL (`getPersonProperties` for tz
|
|
470
|
-
// resolution in the journey durable task) plus the opt-in
|
|
471
|
-
// `bucket.syncToPostHog`
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
562
|
+
// resolution in the journey durable task) plus person writes (the opt-in
|
|
563
|
+
// `bucket.syncToPostHog` mirror) — NOT the outbound catalog firing path
|
|
564
|
+
// (that is the destinations spine). `createHogsendClient` runs in both the
|
|
565
|
+
// API and worker, so this is installed before any worker task runs. May be
|
|
566
|
+
// undefined (no provider configured) — the reads stay no-ops.
|
|
475
567
|
setAnalytics(analytics);
|
|
476
568
|
|
|
477
569
|
// Build + install the outbound DESTINATION registry (Phase 3) the
|
|
@@ -527,6 +619,7 @@ export function createHogsendClient(
|
|
|
527
619
|
emailProvider: provider,
|
|
528
620
|
domainStatus,
|
|
529
621
|
templates,
|
|
622
|
+
analyticsProviders,
|
|
530
623
|
analytics,
|
|
531
624
|
registry,
|
|
532
625
|
bucketRegistry,
|
package/src/env.ts
CHANGED
|
@@ -134,6 +134,21 @@ export const env = createEnv({
|
|
|
134
134
|
CLIENT_MIGRATIONS_FOLDER: z.string().min(1).optional(),
|
|
135
135
|
POSTHOG_API_KEY: z.string().min(1).optional(),
|
|
136
136
|
POSTHOG_HOST: z.string().url().optional(),
|
|
137
|
+
// Personal API key (scoped `person:read`, optionally `person:write`) for
|
|
138
|
+
// person-property READS on the private API. The phc_ project key cannot
|
|
139
|
+
// read — it is public + write-only by PostHog's design. Without this,
|
|
140
|
+
// person reads soft-fail and timezone resolution falls back to contact
|
|
141
|
+
// properties. See the "Analytics access" docs page.
|
|
142
|
+
POSTHOG_PERSONAL_API_KEY: z.string().min(1).optional(),
|
|
143
|
+
// PostHog project id for environment-scoped private endpoints. Discovered
|
|
144
|
+
// automatically via GET /api/projects/@current/ when unset.
|
|
145
|
+
POSTHOG_PROJECT_ID: z.string().min(1).optional(),
|
|
146
|
+
// Private (app) API host override. Defaults to POSTHOG_HOST with the
|
|
147
|
+
// `.i.` ingestion label stripped (eu.i.posthog.com → eu.posthog.com).
|
|
148
|
+
POSTHOG_PRIVATE_HOST: z.string().url().optional(),
|
|
149
|
+
// Selects the ACTIVE analytics provider id out of the registry (env
|
|
150
|
+
// presets + consumer-registered providers). Mirrors EMAIL_PROVIDER.
|
|
151
|
+
ANALYTICS_PROVIDER: z.string().min(1).default("posthog"),
|
|
137
152
|
POSTHOG_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
138
153
|
// When true AND POSTHOG_API_KEY is set, the engine idempotently auto-seeds
|
|
139
154
|
// ONE kind="posthog" webhook endpoint subscribed to the email funnel so the
|
package/src/index.ts
CHANGED
|
@@ -37,7 +37,11 @@ export * from "@hogsend/core";
|
|
|
37
37
|
// omitted here: the engine's public `SendEmailOptions` is the high-level
|
|
38
38
|
// journey-facing send options from `./lib/email.js`; the provider-contract
|
|
39
39
|
// `SendEmailOptions` remains available via `@hogsend/core`.)
|
|
40
|
-
export {
|
|
40
|
+
export {
|
|
41
|
+
defineAnalyticsProvider,
|
|
42
|
+
defineEmailProvider,
|
|
43
|
+
WebhookHandshakeSignal,
|
|
44
|
+
} from "@hogsend/core";
|
|
41
45
|
export {
|
|
42
46
|
BucketRegistry,
|
|
43
47
|
JourneyRegistry,
|
|
@@ -136,6 +140,9 @@ export {
|
|
|
136
140
|
getJourneyRegistrySingleton,
|
|
137
141
|
setJourneyRegistry,
|
|
138
142
|
} from "./journeys/registry-singleton.js";
|
|
143
|
+
// --- Analytics provider registry (the analytics sibling) ---
|
|
144
|
+
export { AnalyticsProviderRegistry } from "./lib/analytics-provider-registry.js";
|
|
145
|
+
export { analyticsProvidersFromEnv } from "./lib/analytics-providers-from-env.js";
|
|
139
146
|
// --- Auth ---
|
|
140
147
|
export {
|
|
141
148
|
type Auth,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { AnalyticsProvider, PostHogService } from "@hogsend/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrap a legacy `PostHogService` (the deprecated PostHog-shaped interface
|
|
5
|
+
* accepted by `createHogsendClient({ analytics })` since before the neutral
|
|
6
|
+
* `AnalyticsProvider` contract existed) so it satisfies the contract the
|
|
7
|
+
* engine now speaks internally. Capabilities are assumed-on: a hand-built
|
|
8
|
+
* service predates capability reporting, and every method is best-effort
|
|
9
|
+
* anyway.
|
|
10
|
+
*
|
|
11
|
+
* Mapping notes:
|
|
12
|
+
* - `setPersonProperties.set` → `identify(distinctId, set)` (the legacy $set
|
|
13
|
+
* path). `setOnce` ALSO maps to `identify` — legacy services have no
|
|
14
|
+
* set-once wire, so overwrite semantics apply; `unset` maps to the legacy
|
|
15
|
+
* raw `$set` capture with `$unset`, mirroring what the bucket sync used to
|
|
16
|
+
* emit directly.
|
|
17
|
+
*/
|
|
18
|
+
export function wrapLegacyAnalyticsService(
|
|
19
|
+
service: PostHogService,
|
|
20
|
+
): AnalyticsProvider {
|
|
21
|
+
return {
|
|
22
|
+
meta: {
|
|
23
|
+
id: "custom",
|
|
24
|
+
name: "Custom analytics service (legacy PostHogService shape)",
|
|
25
|
+
},
|
|
26
|
+
capabilities: { personReads: true, personWrites: true },
|
|
27
|
+
|
|
28
|
+
getPersonProperties(distinctId) {
|
|
29
|
+
return service.getPersonProperties(distinctId);
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async setPersonProperties({ distinctId, set, setOnce, unset }) {
|
|
33
|
+
const merged = { ...(setOnce ?? {}), ...(set ?? {}) };
|
|
34
|
+
if (Object.keys(merged).length > 0) {
|
|
35
|
+
service.identify(distinctId, merged);
|
|
36
|
+
}
|
|
37
|
+
if (unset?.length) {
|
|
38
|
+
service.captureEvent({
|
|
39
|
+
distinctId,
|
|
40
|
+
event: "$set",
|
|
41
|
+
properties: { $unset: unset },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
capture(opts) {
|
|
47
|
+
service.captureEvent(opts);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
async shutdown() {
|
|
51
|
+
await service.shutdown();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Runtime discrimination for the `analytics` option union: a neutral
|
|
58
|
+
* `AnalyticsProvider` carries `meta` + `capture`; the legacy `PostHogService`
|
|
59
|
+
* carries `captureEvent` and no `meta`.
|
|
60
|
+
*/
|
|
61
|
+
export function isAnalyticsProvider(
|
|
62
|
+
value: AnalyticsProvider | PostHogService,
|
|
63
|
+
): value is AnalyticsProvider {
|
|
64
|
+
return (
|
|
65
|
+
typeof (value as AnalyticsProvider).capture === "function" &&
|
|
66
|
+
typeof (value as AnalyticsProvider).meta === "object"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AnalyticsProvider } from "@hogsend/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Container-held registry of analytics providers, keyed by `provider.meta.id`
|
|
5
|
+
* — the analytics sibling of `EmailProviderRegistry`. The container picks ONE
|
|
6
|
+
* `active` provider out of it (env `ANALYTICS_PROVIDER` /
|
|
7
|
+
* `analytics.defaultProvider`, default `"posthog"`) for the identity PULL,
|
|
8
|
+
* person writes, and capture.
|
|
9
|
+
*
|
|
10
|
+
* Keyed with last-writer-wins, so a consumer-supplied provider of the same id
|
|
11
|
+
* overrides an env preset of that id.
|
|
12
|
+
*/
|
|
13
|
+
export class AnalyticsProviderRegistry {
|
|
14
|
+
private byId = new Map<string, AnalyticsProvider>();
|
|
15
|
+
|
|
16
|
+
constructor(providers: AnalyticsProvider[] = []) {
|
|
17
|
+
for (const provider of providers) this.register(provider);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
register(provider: AnalyticsProvider): void {
|
|
21
|
+
this.byId.set(provider.meta.id, provider);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(id: string): AnalyticsProvider | undefined {
|
|
25
|
+
return this.byId.get(id);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getAll(): AnalyticsProvider[] {
|
|
29
|
+
return [...this.byId.values()];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
count(): number {
|
|
33
|
+
return this.byId.size;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AnalyticsProvider } from "@hogsend/core";
|
|
2
|
+
import { createPostHogProvider } from "@hogsend/plugin-posthog";
|
|
3
|
+
import type { env as envSchema } from "../env.js";
|
|
4
|
+
import { getRedis } from "./redis.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Env-driven analytics-provider presets — the analytics sibling of
|
|
8
|
+
* `emailProvidersFromEnv`. PostHog is built when `POSTHOG_API_KEY` is set;
|
|
9
|
+
* person READS additionally need `POSTHOG_PERSONAL_API_KEY` (the public phc_
|
|
10
|
+
* key is write-only by PostHog's design) — without it the provider still
|
|
11
|
+
* captures and writes person properties, and reads soft-fail to the engine's
|
|
12
|
+
* contact-property fallback.
|
|
13
|
+
*
|
|
14
|
+
* Consumer-supplied providers (`analytics.providers` / `analytics.provider`)
|
|
15
|
+
* merge AFTER these in the registry, so a consumer build of the same id wins.
|
|
16
|
+
*/
|
|
17
|
+
export function analyticsProvidersFromEnv(
|
|
18
|
+
env: typeof envSchema,
|
|
19
|
+
): AnalyticsProvider[] {
|
|
20
|
+
const providers: AnalyticsProvider[] = [];
|
|
21
|
+
|
|
22
|
+
if (env.POSTHOG_API_KEY) {
|
|
23
|
+
providers.push(
|
|
24
|
+
createPostHogProvider({
|
|
25
|
+
apiKey: env.POSTHOG_API_KEY,
|
|
26
|
+
host: env.POSTHOG_HOST,
|
|
27
|
+
personalApiKey: env.POSTHOG_PERSONAL_API_KEY,
|
|
28
|
+
projectId: env.POSTHOG_PROJECT_ID,
|
|
29
|
+
privateHost: env.POSTHOG_PRIVATE_HOST,
|
|
30
|
+
redis: getRedis(),
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return providers;
|
|
36
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AnalyticsProvider } from "@hogsend/core";
|
|
2
2
|
import { createOptionalSingleton } from "./singleton.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -24,7 +24,7 @@ import { createOptionalSingleton } from "./singleton.js";
|
|
|
24
24
|
* container resolves `analytics` to `undefined` and installs it here, so every
|
|
25
25
|
* read remains a no-op exactly as before — hence the optional singleton variant.
|
|
26
26
|
*/
|
|
27
|
-
const singleton = createOptionalSingleton<
|
|
27
|
+
const singleton = createOptionalSingleton<AnalyticsProvider>();
|
|
28
28
|
|
|
29
29
|
export const setAnalytics = singleton.set;
|
|
30
30
|
export const getAnalytics = singleton.get;
|
|
@@ -42,16 +42,18 @@ export function syncBucketToPostHog(opts: {
|
|
|
42
42
|
|
|
43
43
|
try {
|
|
44
44
|
if (kind === "entered") {
|
|
45
|
-
//
|
|
46
|
-
posthog.
|
|
45
|
+
// set { key: true } — the provider's person-write wire ($set on PostHog).
|
|
46
|
+
void posthog.setPersonProperties({
|
|
47
|
+
distinctId: userId,
|
|
48
|
+
set: { [propertyKey]: true },
|
|
49
|
+
});
|
|
47
50
|
} else {
|
|
48
|
-
//
|
|
51
|
+
// unset [key] — RECOMMENDED on leave (Section 12). The property is absent
|
|
49
52
|
// unless the user is currently a member, so both `key = true` and
|
|
50
53
|
// `key is set` cohorts behave correctly.
|
|
51
|
-
posthog.
|
|
54
|
+
void posthog.setPersonProperties({
|
|
52
55
|
distinctId: userId,
|
|
53
|
-
|
|
54
|
-
properties: { $unset: [propertyKey] },
|
|
56
|
+
unset: [propertyKey],
|
|
55
57
|
});
|
|
56
58
|
}
|
|
57
59
|
} catch (err) {
|
package/src/lib/contacts.ts
CHANGED
|
@@ -122,6 +122,10 @@ interface ResolveKey {
|
|
|
122
122
|
value: string;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/** Postgres uuid syntax — guards the `contacts.id` fallback cast below. */
|
|
126
|
+
const UUID_PATTERN =
|
|
127
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
128
|
+
|
|
125
129
|
/**
|
|
126
130
|
* Look up the single live contact owning `(kind, value)`, falling back to
|
|
127
131
|
* `contact_aliases` on a miss so a stale (loser/promoted) key still resolves to
|
|
@@ -153,14 +157,34 @@ async function findByKey(tx: Tx, key: ResolveKey): Promise<ContactRow | null> {
|
|
|
153
157
|
),
|
|
154
158
|
)
|
|
155
159
|
.limit(1);
|
|
156
|
-
if (
|
|
160
|
+
if (alias[0]) {
|
|
161
|
+
const aliased = await tx
|
|
162
|
+
.select()
|
|
163
|
+
.from(contacts)
|
|
164
|
+
.where(
|
|
165
|
+
and(eq(contacts.id, alias[0].contactId), isNull(contacts.deletedAt)),
|
|
166
|
+
)
|
|
167
|
+
.limit(1);
|
|
168
|
+
if (aliased[0]) return aliased[0];
|
|
169
|
+
}
|
|
157
170
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
171
|
+
// Row-id fallback (external keys only): an email-only / anonymous-only
|
|
172
|
+
// contact's canonical key (`external_id ?? anonymous_id ?? id`) IS its row id,
|
|
173
|
+
// and that key leaves the system — in Hatchet event payloads, outbound
|
|
174
|
+
// destination `userId`s, and `hs_t` identity tokens. When such a key round-trips
|
|
175
|
+
// back through ingest as a `userId` (e.g. a PostHog webhook forwarding events
|
|
176
|
+
// for a person identified via the `hs_t` stitch), it must resolve to the SAME
|
|
177
|
+
// contact, not mint a duplicate keyed by the old row's id.
|
|
178
|
+
if (key.kind === "external" && UUID_PATTERN.test(key.value)) {
|
|
179
|
+
const byId = await tx
|
|
180
|
+
.select()
|
|
181
|
+
.from(contacts)
|
|
182
|
+
.where(and(eq(contacts.id, key.value), isNull(contacts.deletedAt)))
|
|
183
|
+
.limit(1);
|
|
184
|
+
return byId[0] ?? null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
164
188
|
}
|
|
165
189
|
|
|
166
190
|
/**
|
|
@@ -929,6 +953,20 @@ async function recordMergeAliases(
|
|
|
929
953
|
reason: "merge",
|
|
930
954
|
});
|
|
931
955
|
}
|
|
956
|
+
// When the loser had neither external_id nor anonymous_id, its CANONICAL key
|
|
957
|
+
// (`external_id ?? anonymous_id ?? id`) was its row id — and that key has
|
|
958
|
+
// circulated (Hatchet payloads, outbound `userId`s, `hs_t` tokens). Alias it
|
|
959
|
+
// as an external key so a round-trip still resolves to the survivor after the
|
|
960
|
+
// soft-delete takes the row out of findByKey's id fallback.
|
|
961
|
+
if (!loser.externalId && !loser.anonymousId) {
|
|
962
|
+
aliasRows.push({
|
|
963
|
+
contactId: survivorId,
|
|
964
|
+
aliasKind: "external",
|
|
965
|
+
aliasValue: loser.id,
|
|
966
|
+
fromContactId: loser.id,
|
|
967
|
+
reason: "merge",
|
|
968
|
+
});
|
|
969
|
+
}
|
|
932
970
|
|
|
933
971
|
if (aliasRows.length === 0) return;
|
|
934
972
|
|
package/src/lib/ingestion.ts
CHANGED
|
@@ -36,6 +36,15 @@ export interface ExitResult {
|
|
|
36
36
|
export interface IngestResult {
|
|
37
37
|
stored: boolean;
|
|
38
38
|
exits: ExitResult[];
|
|
39
|
+
/**
|
|
40
|
+
* The contact's canonical text key after this ingest's identity resolve
|
|
41
|
+
* (`external_id ?? anonymous_id ?? id`). This is the same key outbound
|
|
42
|
+
* destinations emit as `userId` and `hs_t` identity tokens carry — callers
|
|
43
|
+
* (e.g. a site's subscribe endpoint) can hand it to their analytics
|
|
44
|
+
* `identify()` so the session joins the person the contact's email events
|
|
45
|
+
* land on, without any PII leaving Hogsend.
|
|
46
|
+
*/
|
|
47
|
+
contactKey: string;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
export async function ingestEvent(opts: {
|
|
@@ -85,7 +94,7 @@ export async function ingestEvent(opts: {
|
|
|
85
94
|
.returning({ id: userEvents.id });
|
|
86
95
|
|
|
87
96
|
if (result.length === 0) {
|
|
88
|
-
return { stored: false, exits: [] };
|
|
97
|
+
return { stored: false, exits: [], contactKey: resolvedKey };
|
|
89
98
|
}
|
|
90
99
|
idempotentInsertId = result[0]?.id;
|
|
91
100
|
} else {
|
|
@@ -185,7 +194,7 @@ export async function ingestEvent(opts: {
|
|
|
185
194
|
exits: exits.filter((e) => e.exited).length,
|
|
186
195
|
});
|
|
187
196
|
|
|
188
|
-
return { stored: true, exits };
|
|
197
|
+
return { stored: true, exits, contactKey: resolvedKey };
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
async function checkExits(
|
package/src/lib/posthog.ts
CHANGED
|
@@ -4,12 +4,28 @@ import { getRedis } from "./redis.js";
|
|
|
4
4
|
|
|
5
5
|
let _posthog: PostHogService | undefined;
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Lazy PostHog service singleton for STANDALONE consumer imports (journeys
|
|
9
|
+
* calling `getPostHog()` for capture/identify/flags). Reads process.env
|
|
10
|
+
* directly so it works without a container reference.
|
|
11
|
+
*
|
|
12
|
+
* Person READS additionally require `POSTHOG_PERSONAL_API_KEY` (the phc_
|
|
13
|
+
* project key is write-only by PostHog's design); without it
|
|
14
|
+
* `getPersonProperties` soft-fails to `{}`.
|
|
15
|
+
*
|
|
16
|
+
* The engine's own analytics path now flows through the neutral
|
|
17
|
+
* `AnalyticsProvider` registry (see `analyticsProvidersFromEnv` /
|
|
18
|
+
* `createHogsendClient`'s `analytics` option) — this stays for consumer code.
|
|
19
|
+
*/
|
|
7
20
|
export function getPostHog(): PostHogService | undefined {
|
|
8
21
|
if (!process.env.POSTHOG_API_KEY) return undefined;
|
|
9
22
|
if (!_posthog) {
|
|
10
23
|
_posthog = createPostHogService({
|
|
11
24
|
apiKey: process.env.POSTHOG_API_KEY,
|
|
12
25
|
host: process.env.POSTHOG_HOST,
|
|
26
|
+
personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY,
|
|
27
|
+
projectId: process.env.POSTHOG_PROJECT_ID,
|
|
28
|
+
privateHost: process.env.POSTHOG_PRIVATE_HOST,
|
|
13
29
|
redis: getRedis(),
|
|
14
30
|
});
|
|
15
31
|
}
|
|
@@ -25,6 +25,11 @@ const eventResponseSchema = z.object({
|
|
|
25
25
|
exited: z.boolean(),
|
|
26
26
|
}),
|
|
27
27
|
),
|
|
28
|
+
// The contact's canonical key (`external_id ?? anonymous_id ?? id`) — the
|
|
29
|
+
// same key outbound destinations and `hs_t` identity tokens carry, so the
|
|
30
|
+
// caller can `identify()` its analytics session against the contact without
|
|
31
|
+
// any PII round-trip.
|
|
32
|
+
contactKey: z.string(),
|
|
28
33
|
// Present only when the event was durably ingested but the (non-atomic,
|
|
29
34
|
// post-ingest) list-membership write failed. The ingest itself succeeded —
|
|
30
35
|
// surfaced as a warning on a 202, not a 400 that conflates "nothing happened"
|
package/src/worker.ts
CHANGED
|
@@ -108,8 +108,9 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
|
|
|
108
108
|
_worker?.stop(),
|
|
109
109
|
// Shut down the injected analytics instance (same object the worker's
|
|
110
110
|
// tasks use), not the module singleton. Undefined when no analytics is
|
|
111
|
-
// configured
|
|
112
|
-
|
|
111
|
+
// configured, and `shutdown` is optional on the provider contract —
|
|
112
|
+
// both optional chains make that a no-op.
|
|
113
|
+
container.analytics?.shutdown?.(),
|
|
113
114
|
getRedisIfConnected()?.quit(),
|
|
114
115
|
]);
|
|
115
116
|
}
|