@hogsend/engine 0.18.0 → 0.20.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 +121 -21
- package/src/destinations/presets/posthog.ts +132 -30
- package/src/env.ts +15 -0
- package/src/index.ts +34 -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 +79 -0
- package/src/lib/analytics-singleton.ts +2 -2
- package/src/lib/bucket-posthog-sync.ts +8 -6
- package/src/lib/oauth-token-manager.ts +353 -0
- package/src/lib/posthog.ts +16 -0
- package/src/lib/provider-credentials.ts +250 -0
- package/src/lib/provision-posthog-loop.ts +722 -0
- package/src/lib/seed-posthog-destination.ts +38 -7
- package/src/routes/admin/analytics.ts +261 -0
- package/src/routes/admin/index.ts +4 -0
- package/src/routes/admin/provider-credentials.ts +184 -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.20.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/db": "^0.
|
|
45
|
-
"@hogsend/email": "^0.
|
|
46
|
-
"@hogsend/plugin-posthog": "^0.
|
|
47
|
-
"@hogsend/plugin-resend": "^0.
|
|
43
|
+
"@hogsend/core": "^0.20.0",
|
|
44
|
+
"@hogsend/db": "^0.20.0",
|
|
45
|
+
"@hogsend/email": "^0.20.0",
|
|
46
|
+
"@hogsend/plugin-posthog": "^0.20.0",
|
|
47
|
+
"@hogsend/plugin-resend": "^0.20.0"
|
|
48
48
|
},
|
|
49
49
|
"optionalDependencies": {
|
|
50
|
-
"@hogsend/plugin-postmark": "^0.
|
|
50
|
+
"@hogsend/plugin-postmark": "^0.20.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,76 @@ 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, { db, logger }),
|
|
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
|
+
// OAuth-capable providers resolve their credential ASYNC (the env factory
|
|
551
|
+
// logs the truthful nudge after the load settles) — a sync check here would
|
|
552
|
+
// log a false "DISABLED" on every boot of a connected instance.
|
|
553
|
+
if (
|
|
554
|
+
analytics &&
|
|
555
|
+
!analytics.capabilities.oauth &&
|
|
556
|
+
!analytics.capabilities.personReads
|
|
557
|
+
) {
|
|
558
|
+
logger.info(
|
|
559
|
+
`analytics provider "${analytics.meta.id}" has person reads DISABLED — ` +
|
|
560
|
+
"timezone resolution falls back to contact properties. For PostHog, " +
|
|
561
|
+
"set POSTHOG_PERSONAL_API_KEY or run `hogsend connect posthog`. " +
|
|
562
|
+
"Docs: https://hogsend.com/docs/guides/analytics-access",
|
|
563
|
+
);
|
|
564
|
+
}
|
|
466
565
|
|
|
467
566
|
// Expose the resolved analytics instance to the module-level task-execution
|
|
468
567
|
// sites that have no client reference. Its role is NARROW (see the
|
|
469
568
|
// `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
|
-
//
|
|
569
|
+
// resolution in the journey durable task) plus person writes (the opt-in
|
|
570
|
+
// `bucket.syncToPostHog` mirror) — NOT the outbound catalog firing path
|
|
571
|
+
// (that is the destinations spine). `createHogsendClient` runs in both the
|
|
572
|
+
// API and worker, so this is installed before any worker task runs. May be
|
|
573
|
+
// undefined (no provider configured) — the reads stay no-ops.
|
|
475
574
|
setAnalytics(analytics);
|
|
476
575
|
|
|
477
576
|
// Build + install the outbound DESTINATION registry (Phase 3) the
|
|
@@ -527,6 +626,7 @@ export function createHogsendClient(
|
|
|
527
626
|
emailProvider: provider,
|
|
528
627
|
domainStatus,
|
|
529
628
|
templates,
|
|
629
|
+
analyticsProviders,
|
|
530
630
|
analytics,
|
|
531
631
|
registry,
|
|
532
632
|
bucketRegistry,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OutboundPayloads } from "../../lib/outbound.js";
|
|
1
2
|
import { WEBHOOK_EVENT_TYPES } from "../../lib/webhook-signing.js";
|
|
2
3
|
import { defineDestination } from "../define-destination.js";
|
|
3
4
|
|
|
@@ -16,6 +17,60 @@ interface PostHogConfig {
|
|
|
16
17
|
* be remapped this way; absent or unmapped keys pass through unchanged.
|
|
17
18
|
*/
|
|
18
19
|
eventNames?: Record<string, string>;
|
|
20
|
+
/**
|
|
21
|
+
* Person-property propagation (the contact → analytics-person rail). When
|
|
22
|
+
* true, `contact.created` / `contact.updated` events become `$set` captures
|
|
23
|
+
* of the contact's `properties` under the contact's canonical key — the
|
|
24
|
+
* SAME distinct id the identify loop uses — so PostHog person profiles
|
|
25
|
+
* accumulate contact truth (plan, role, lifecycle stage…) and cohorts can
|
|
26
|
+
* segment on it. `contact.unsubscribed` (scope `all`) sets
|
|
27
|
+
* `hogsend_unsubscribed: true`.
|
|
28
|
+
*
|
|
29
|
+
* Privacy posture: ONLY `contact.properties` syncs — never email or any
|
|
30
|
+
* other identifier. Anything but a boolean `true` (config is a loose jsonb
|
|
31
|
+
* bag) leaves `contact.*` events SKIPPED entirely — they carry no
|
|
32
|
+
* `userId`/`to`, so the generic capture branch could never address them
|
|
33
|
+
* correctly anyway.
|
|
34
|
+
*/
|
|
35
|
+
syncPersons?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The person property a scope-`all` unsubscribe sets (default
|
|
38
|
+
* `hogsend_unsubscribed`) — overridable like the bucket mirror's
|
|
39
|
+
* `postHogPropertyKey`, so operators can match their own naming scheme.
|
|
40
|
+
*/
|
|
41
|
+
unsubscribedPropertyKey?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The one place the PostHog capture request is built — all three transform
|
|
46
|
+
* branches (person `$set`, `email.action`, generic catalog capture) share it,
|
|
47
|
+
* so a change to the capture wire shape happens once.
|
|
48
|
+
*/
|
|
49
|
+
function captureRequest(opts: {
|
|
50
|
+
host: string;
|
|
51
|
+
apiKey: string;
|
|
52
|
+
event: string;
|
|
53
|
+
distinctId: string | undefined;
|
|
54
|
+
timestamp: string;
|
|
55
|
+
properties: Record<string, unknown>;
|
|
56
|
+
}): {
|
|
57
|
+
url: string;
|
|
58
|
+
method: string;
|
|
59
|
+
headers: Record<string, string>;
|
|
60
|
+
body: string;
|
|
61
|
+
} {
|
|
62
|
+
return {
|
|
63
|
+
url: `${opts.host}/capture/`,
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "Content-Type": "application/json" },
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
api_key: opts.apiKey,
|
|
68
|
+
event: opts.event,
|
|
69
|
+
distinct_id: opts.distinctId,
|
|
70
|
+
timestamp: opts.timestamp,
|
|
71
|
+
properties: { ...opts.properties, $lib: "hogsend" },
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
19
74
|
}
|
|
20
75
|
|
|
21
76
|
/**
|
|
@@ -47,6 +102,62 @@ export const posthogDestination = defineDestination({
|
|
|
47
102
|
);
|
|
48
103
|
}
|
|
49
104
|
const host = config.host ?? "https://us.i.posthog.com";
|
|
105
|
+
|
|
106
|
+
// Person-property propagation: `contact.*` events carry a contact payload
|
|
107
|
+
// (id/externalId/email/properties), NOT the userId/to identity chain the
|
|
108
|
+
// generic capture branch keys on — so they are handled here exclusively
|
|
109
|
+
// and SKIPPED (null) when `config.syncPersons` is off.
|
|
110
|
+
if (envelope.type.startsWith("contact.")) {
|
|
111
|
+
// Strict `=== true`: config is a loose jsonb bag, and a stray string
|
|
112
|
+
// value ("false") must not enable the sync.
|
|
113
|
+
if (config.syncPersons !== true) return null;
|
|
114
|
+
|
|
115
|
+
const setCapture = (distinctId: string, set: Record<string, unknown>) =>
|
|
116
|
+
captureRequest({
|
|
117
|
+
host,
|
|
118
|
+
apiKey: config.apiKey as string,
|
|
119
|
+
event: "$set",
|
|
120
|
+
distinctId,
|
|
121
|
+
timestamp: envelope.timestamp,
|
|
122
|
+
properties: { $set: set },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
envelope.type === "contact.created" ||
|
|
127
|
+
envelope.type === "contact.updated"
|
|
128
|
+
) {
|
|
129
|
+
const contact =
|
|
130
|
+
envelope.data as unknown as OutboundPayloads["contact.updated"];
|
|
131
|
+
const props = contact.properties ?? {};
|
|
132
|
+
// Nothing to propagate — a successful no-op, not a delivery failure.
|
|
133
|
+
if (Object.keys(props).length === 0) return null;
|
|
134
|
+
// The contact's canonical key (externalId ?? id) — the same distinct
|
|
135
|
+
// id the identify loop and hs_t stitch use, so the $set lands on the
|
|
136
|
+
// person the contact's web sessions and email events already share.
|
|
137
|
+
// Known limitation: the serialized payload omits anonymousId, so an
|
|
138
|
+
// anonymous-keyed contact syncs under its row id rather than its
|
|
139
|
+
// anonymous key — fixed properly when contact.* payloads grow a
|
|
140
|
+
// first-class `contactKey` field.
|
|
141
|
+
return setCapture(contact.externalId ?? contact.id, props);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (envelope.type === "contact.unsubscribed") {
|
|
145
|
+
const data =
|
|
146
|
+
envelope.data as unknown as OutboundPayloads["contact.unsubscribed"];
|
|
147
|
+
// Category-scoped opt-outs are too granular for a person flag, and a
|
|
148
|
+
// payload without externalId can't be addressed safely (the canonical
|
|
149
|
+
// key of an email-only contact is its row id, which this payload
|
|
150
|
+
// doesn't carry — guessing by email would mint a wrong person).
|
|
151
|
+
if (data.scope !== "all" || !data.externalId) return null;
|
|
152
|
+
const flag = config.unsubscribedPropertyKey ?? "hogsend_unsubscribed";
|
|
153
|
+
return setCapture(data.externalId, { [flag]: true });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// contact.deleted: PostHog person deletion is a private-API operation,
|
|
157
|
+
// not a capture — out of scope for this rail.
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
50
161
|
const data = envelope.data as {
|
|
51
162
|
userId?: string | null;
|
|
52
163
|
to?: string | null;
|
|
@@ -69,38 +180,29 @@ export const posthogDestination = defineDestination({
|
|
|
69
180
|
userId: string | null;
|
|
70
181
|
at: string;
|
|
71
182
|
};
|
|
72
|
-
return {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
linkId: action.linkId,
|
|
86
|
-
$lib: "hogsend",
|
|
87
|
-
},
|
|
88
|
-
}),
|
|
89
|
-
};
|
|
183
|
+
return captureRequest({
|
|
184
|
+
host,
|
|
185
|
+
apiKey: config.apiKey,
|
|
186
|
+
event: action.event,
|
|
187
|
+
distinctId,
|
|
188
|
+
timestamp: envelope.timestamp,
|
|
189
|
+
properties: {
|
|
190
|
+
...(action.properties ?? {}),
|
|
191
|
+
emailSendId: action.emailSendId,
|
|
192
|
+
templateKey: action.templateKey,
|
|
193
|
+
linkId: action.linkId,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
90
196
|
}
|
|
91
197
|
// Optional event-name remap (identity by default).
|
|
92
198
|
const eventName = config.eventNames?.[envelope.type] ?? envelope.type;
|
|
93
|
-
return {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
timestamp: envelope.timestamp,
|
|
102
|
-
properties: { ...envelope.data, $lib: "hogsend" },
|
|
103
|
-
}),
|
|
104
|
-
};
|
|
199
|
+
return captureRequest({
|
|
200
|
+
host,
|
|
201
|
+
apiKey: config.apiKey,
|
|
202
|
+
event: eventName,
|
|
203
|
+
distinctId,
|
|
204
|
+
timestamp: envelope.timestamp,
|
|
205
|
+
properties: envelope.data as Record<string, unknown>,
|
|
206
|
+
});
|
|
105
207
|
},
|
|
106
208
|
});
|
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,
|
|
@@ -220,6 +227,18 @@ export {
|
|
|
220
227
|
// --- Logging ---
|
|
221
228
|
export { createLogger, type Logger } from "./lib/logger.js";
|
|
222
229
|
export { createTrackedMailer } from "./lib/mailer.js";
|
|
230
|
+
// --- OAuth token manager (provider access-token cache + refresh) ---
|
|
231
|
+
export {
|
|
232
|
+
ABSENT_RECHECK_MS,
|
|
233
|
+
type CredentialState,
|
|
234
|
+
type CredentialStore,
|
|
235
|
+
createTokenManager,
|
|
236
|
+
EXPIRY_SKEW_MS,
|
|
237
|
+
FAILURE_BACKOFF_MS,
|
|
238
|
+
HOGSEND_POSTHOG_CLIENT_ID,
|
|
239
|
+
oauthCredentialPayloadSchema,
|
|
240
|
+
type TokenManager,
|
|
241
|
+
} from "./lib/oauth-token-manager.js";
|
|
223
242
|
// --- Outbound webhooks: emit spine (Section 1.4) ---
|
|
224
243
|
export {
|
|
225
244
|
emitOutbound,
|
|
@@ -228,6 +247,18 @@ export {
|
|
|
228
247
|
type OutboundPayloads,
|
|
229
248
|
} from "./lib/outbound.js";
|
|
230
249
|
export { getPostHog } from "./lib/posthog.js";
|
|
250
|
+
// --- Provider credentials (encrypted-at-rest OAuth token store) ---
|
|
251
|
+
export {
|
|
252
|
+
type CredentialKind,
|
|
253
|
+
type DecryptedProviderCredential,
|
|
254
|
+
deleteProviderCredential,
|
|
255
|
+
getProviderCredential,
|
|
256
|
+
type OAuthCredentialPayload,
|
|
257
|
+
ProviderCredentialDecryptError,
|
|
258
|
+
type ProviderCredentialMeta,
|
|
259
|
+
saveProviderCredential,
|
|
260
|
+
toCredentialMeta,
|
|
261
|
+
} from "./lib/provider-credentials.js";
|
|
231
262
|
export {
|
|
232
263
|
type AuthSecondaryStorage,
|
|
233
264
|
createRedisSecondaryStorage,
|
|
@@ -235,6 +266,8 @@ export {
|
|
|
235
266
|
} from "./lib/redis.js";
|
|
236
267
|
// --- Self-service password reset (engine-owned, self-contained email) ---
|
|
237
268
|
export { sendResetPasswordEmail } from "./lib/reset-email.js";
|
|
269
|
+
// --- PostHog destination seed (idempotent; ENABLE_POSTHOG_DESTINATION) ---
|
|
270
|
+
export { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
|
|
238
271
|
export {
|
|
239
272
|
type ConfirmSemanticClickInput,
|
|
240
273
|
type ConfirmSemanticClickResult,
|
|
@@ -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
|
+
}
|