@hogsend/engine 0.18.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.18.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.18.0",
44
- "@hogsend/db": "^0.18.0",
45
- "@hogsend/email": "^0.18.0",
46
- "@hogsend/plugin-posthog": "^0.18.0",
47
- "@hogsend/plugin-resend": "^0.18.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.18.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 { EmailProvider, PostHogService, TimeZone } from "@hogsend/core";
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
- analytics?: PostHogService;
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 PostHog-style analytics service. As of the destinations spine its role
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 now fans out durably via
177
- * DESTINATIONS on the webhook spine, keyed by `webhook_endpoints.kind`). It
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
- * This read role is UNCHANGED and load-bearing.
183
- * 2. The opt-in `bucket.syncToPostHog` person-property mirror — `$set`/`$unset`
184
- * of a boolean cohort property on bucket transitions (`bucket-posthog-sync`).
185
- * Off by default; PostHog `$set`/`$unset` identity semantics have no
186
- * vendor-neutral envelope, so this stays a PostHog-direct write.
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. Defaults to {@link getPostHog} (a no-op when
190
- * `POSTHOG_API_KEY` is unset).
222
+ * it for the PULL.
191
223
  */
192
- analytics?: PostHogService;
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
- const analytics = opts.analytics ?? getPostHog();
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` person-property mirror — NOT the outbound catalog
472
- // firing path (that is the destinations spine). `createHogsendClient` runs in
473
- // both the API and worker, so this is installed before any worker task runs.
474
- // May be undefined (no POSTHOG_API_KEY) — the reads stay no-ops.
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 { defineEmailProvider, WebhookHandshakeSignal } from "@hogsend/core";
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 { PostHogService } from "@hogsend/core";
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<PostHogService>();
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
- // $set { key: true } — mirrors plugin-posthog identify() ($set path).
46
- posthog.identify(userId, { [propertyKey]: true });
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
- // $unset [key] — RECOMMENDED on leave (Section 12). The property is absent
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.captureEvent({
54
+ void posthog.setPersonProperties({
52
55
  distinctId: userId,
53
- event: "$set",
54
- properties: { $unset: [propertyKey] },
56
+ unset: [propertyKey],
55
57
  });
56
58
  }
57
59
  } catch (err) {
@@ -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
  }
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 the optional chain makes that a no-op.
112
- container.analytics?.shutdown(),
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
  }