diff --git a/.changeset/posthog-explicit-di-config.md b/.changeset/posthog-explicit-di-config.md new file mode 100644 index 000000000..8eace5aaf --- /dev/null +++ b/.changeset/posthog-explicit-di-config.md @@ -0,0 +1,5 @@ +--- +"@croco/integrations-posthog": patch +--- + +Make `PostHogClient` resolvable through Croco DI after validated configuration registration. diff --git a/packages/docs/src/content/docs/api/integrations-posthog/src/functions/registerPostHogConfig.md b/packages/docs/src/content/docs/api/integrations-posthog/src/functions/registerPostHogConfig.md new file mode 100644 index 000000000..37ba0bfb1 --- /dev/null +++ b/packages/docs/src/content/docs/api/integrations-posthog/src/functions/registerPostHogConfig.md @@ -0,0 +1,24 @@ +--- +editUrl: false +next: false +prev: false +title: "registerPostHogConfig" +--- + +> **registerPostHogConfig**(`config`): `Readonly`\<[`PostHogConfig`](/api/integrations-posthog/src/interfaces/posthogconfig/)\> + +PostHog 설정을 검증하고 환경 기반 host를 정규화한 뒤 Croco DI에 등록합니다. + +## Parameters + +### config + +[`PostHogConfig`](/api/integrations-posthog/src/interfaces/posthogconfig/) + +등록할 PostHog API key와 선택적 HTTP(S) host입니다. + +## Returns + +`Readonly`\<[`PostHogConfig`](/api/integrations-posthog/src/interfaces/posthogconfig/)\> + +컨테이너에 등록된 동결 설정입니다. diff --git a/packages/docs/src/content/docs/api/integrations-posthog/src/variables/POSTHOG_CONFIG_TOKEN.md b/packages/docs/src/content/docs/api/integrations-posthog/src/variables/POSTHOG_CONFIG_TOKEN.md new file mode 100644 index 000000000..b7b7f76ed --- /dev/null +++ b/packages/docs/src/content/docs/api/integrations-posthog/src/variables/POSTHOG_CONFIG_TOKEN.md @@ -0,0 +1,10 @@ +--- +editUrl: false +next: false +prev: false +title: "POSTHOG_CONFIG_TOKEN" +--- + +> `const` **POSTHOG_CONFIG_TOKEN**: [`Token`](/api/framework-context/src/classes/token/)\<`Readonly`\<[`PostHogConfig`](/api/integrations-posthog/src/interfaces/posthogconfig/)\>\> + +PostHog 설정을 Croco DI에 등록하고 조회할 때 사용하는 토큰입니다. diff --git a/packages/integrations-posthog/README.md b/packages/integrations-posthog/README.md index c4b3377ba..5d0bb1812 100644 --- a/packages/integrations-posthog/README.md +++ b/packages/integrations-posthog/README.md @@ -10,9 +10,34 @@ PostHog through the Croco integration layer. - `PostHogClient` - initializes and wraps the PostHog Node client. - `PostHogConfig` - configuration type for API key and host settings. +- `POSTHOG_CONFIG_TOKEN` - typed DI token for PostHog configuration. +- `registerPostHogConfig` - validates and registers configuration before client resolution. - `PostHogConfigProblem` - stable Problem for missing or invalid PostHog config. -## Usage +## Dependency injection + +Register configuration during application startup before resolving `PostHogClient` or a component +that depends on it. The API key must be non-empty and the host must be an HTTP(S) URL. + +```typescript +import { Container } from "@croco/framework-context"; +import { PostHogClient, registerPostHogConfig } from "@croco/integrations-posthog"; + +registerPostHogConfig({ + apiKey: process.env.POSTHOG_API_KEY ?? "", + host: process.env.POSTHOG_HOST, +}); + +const posthog = Container.get(PostHogClient); +``` + +Missing registration fails with the stable `framework-context/di-resolution-failed` diagnostic. +Invalid registered values fail with `integrations-posthog/missing-config` before the container is +mutated. + +## Direct construction + +Direct construction remains supported and applies the same configuration validation. ```typescript import { PostHogClient } from "@croco/integrations-posthog"; diff --git a/packages/integrations-posthog/src/index.ts b/packages/integrations-posthog/src/index.ts index b1e9157dc..768162112 100644 --- a/packages/integrations-posthog/src/index.ts +++ b/packages/integrations-posthog/src/index.ts @@ -7,4 +7,5 @@ */ export type { PostHogConfig } from "./libs/PostHogClient"; export { PostHogClient } from "./libs/PostHogClient"; +export { POSTHOG_CONFIG_TOKEN, registerPostHogConfig } from "./libs/PostHogConfig"; export { PostHogConfigProblem } from "./libs/problems/PostHogProblems"; diff --git a/packages/integrations-posthog/src/libs/PostHogClient.ts b/packages/integrations-posthog/src/libs/PostHogClient.ts index b5a29ba40..910ac5ec2 100644 --- a/packages/integrations-posthog/src/libs/PostHogClient.ts +++ b/packages/integrations-posthog/src/libs/PostHogClient.ts @@ -1,34 +1,24 @@ -import { Component, Container, type ILogger, LOGGER_TOKEN } from "@croco/framework-context"; +import { Component, Inject } from "@croco/framework-context"; import { PostHog } from "posthog-node"; -import { PostHogConfigProblem } from "./problems/PostHogProblems"; -export interface PostHogConfig { - apiKey: string; - host?: string; -} +import { + POSTHOG_CONFIG_TOKEN, + validatePostHogConfig, + warnAboutEnvironmentHost, +} from "./PostHogConfig"; +import type { PostHogConfig } from "./PostHogConfig"; + +export type { PostHogConfig } from "./PostHogConfig"; @Component() export class PostHogClient { private client: PostHog; - constructor(config: PostHogConfig) { - const envHost = process.env.POSTHOG_HOST; - const host = config.host ?? envHost; - - if (!host) { - throw new PostHogConfigProblem( - "[PostHogClient] PostHog host is required for data residency compliance. " + - "Set host in config or POSTHOG_HOST env var. " + - "Default (app.posthog.com) routes data to US servers.", - ); - } + constructor(@Inject(POSTHOG_CONFIG_TOKEN) config: PostHogConfig) { + const host = validatePostHogConfig(config); - if (!config.host && envHost) { - const logger = Container.get(LOGGER_TOKEN) as ILogger; - logger.warn( - "[PostHogClient] POSTHOG_HOST env var is used for PostHog host. " + - "Set host explicitly in config to confirm data residency compliance.", - ); + if (!config.host) { + warnAboutEnvironmentHost(); } this.client = new PostHog(config.apiKey, { diff --git a/packages/integrations-posthog/src/libs/PostHogConfig.ts b/packages/integrations-posthog/src/libs/PostHogConfig.ts new file mode 100644 index 000000000..3d047e3ac --- /dev/null +++ b/packages/integrations-posthog/src/libs/PostHogConfig.ts @@ -0,0 +1,64 @@ +import { Container, LOGGER_TOKEN, Token } from "@croco/framework-context"; + +import { PostHogConfigProblem } from "./problems/PostHogProblems"; + +export interface PostHogConfig { + apiKey: string; + host?: string; +} + +/** PostHog 설정을 Croco DI에 등록하고 조회할 때 사용하는 토큰입니다. */ +export const POSTHOG_CONFIG_TOKEN = new Token>("PostHogConfig"); + +/** + * PostHog 설정을 검증하고 환경 기반 host를 정규화한 뒤 Croco DI에 등록합니다. + * + * @param config - 등록할 PostHog API key와 선택적 HTTP(S) host입니다. + * @returns 컨테이너에 등록된 동결 설정입니다. + */ +export function registerPostHogConfig(config: PostHogConfig): Readonly { + const host = validatePostHogConfig(config); + + if (!config.host) { + warnAboutEnvironmentHost(); + } + + const registeredConfig = Object.freeze({ ...config, host }); + Container.set(POSTHOG_CONFIG_TOKEN, registeredConfig); + return registeredConfig; +} + +export function warnAboutEnvironmentHost(): void { + Container.getOptional(LOGGER_TOKEN)?.warn( + "[PostHogClient] POSTHOG_HOST env var is used for PostHog host. " + + "Set host explicitly in config to confirm data residency compliance.", + ); +} + +export function validatePostHogConfig(config: PostHogConfig): string { + if (typeof config?.apiKey !== "string" || config.apiKey.trim().length === 0) { + throw new PostHogConfigProblem("[PostHogClient] PostHog apiKey must be a non-empty string."); + } + + const host = config.host ?? process.env.POSTHOG_HOST; + if (!host) { + throw new PostHogConfigProblem( + "[PostHogClient] PostHog host is required for data residency compliance. " + + "Set host in config or POSTHOG_HOST env var. " + + "Default (app.posthog.com) routes data to US servers.", + ); + } + + let parsedHost: URL; + try { + parsedHost = new URL(host); + } catch { + throw new PostHogConfigProblem("[PostHogClient] PostHog host must be a valid HTTP(S) URL."); + } + + if (parsedHost.protocol !== "http:" && parsedHost.protocol !== "https:") { + throw new PostHogConfigProblem("[PostHogClient] PostHog host must be a valid HTTP(S) URL."); + } + + return host; +} diff --git a/packages/integrations-posthog/src/tests/PostHogClient.spec.ts b/packages/integrations-posthog/src/tests/PostHogClient.spec.ts index 0b12a8625..d2bf4e305 100644 --- a/packages/integrations-posthog/src/tests/PostHogClient.spec.ts +++ b/packages/integrations-posthog/src/tests/PostHogClient.spec.ts @@ -1,9 +1,11 @@ import "reflect-metadata"; -import { Container, LOGGER_TOKEN } from "@croco/framework-context"; +import { Container, ContainerResolutionProblem, LOGGER_TOKEN } from "@croco/framework-context"; import type { ILogger } from "@croco/framework-context"; import { PostHog } from "posthog-node"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PostHogClient } from "../libs/PostHogClient"; +import { POSTHOG_CONFIG_TOKEN, registerPostHogConfig } from "../libs/PostHogConfig"; +import { PostHogConfigProblem } from "../libs/problems/PostHogProblems"; vi.mock("posthog-node", () => { const PostHogMock = vi.fn(); @@ -19,6 +21,16 @@ const HOST_REQUIRED_MESSAGE = "Set host in config or POSTHOG_HOST env var. " + "Default (app.posthog.com) routes data to US servers."; +function captureError(action: () => void): unknown { + try { + action(); + } catch (error) { + return error; + } + + throw new Error("Expected action to fail"); +} + describe("PostHogClient", () => { let client!: PostHogClient; let loggerMock: { warn: ReturnType }; @@ -52,6 +64,90 @@ describe("PostHogClient", () => { expect(shutdownSpy).toHaveBeenCalled(); }); + it("should resolve through Container after configuration is registered", () => { + Container.reset(); + Container.register(PostHogClient, "singleton"); + Container.set(LOGGER_TOKEN, loggerMock as unknown as ILogger); + const config = registerPostHogConfig({ + apiKey: "registered-key", + host: "https://registered.posthog.example", + }); + + const resolved = Container.get(PostHogClient); + + expect(Container.get(POSTHOG_CONFIG_TOKEN)).toBe(config); + expect(resolved).toBe(Container.get(PostHogClient)); + expect(PostHog).toHaveBeenLastCalledWith("registered-key", { + host: "https://registered.posthog.example", + }); + }); + + it("should freeze the resolved environment host when configuration is registered", () => { + Container.reset(); + Container.register(PostHogClient, "singleton"); + Container.set(LOGGER_TOKEN, loggerMock as unknown as ILogger); + vi.stubEnv("POSTHOG_HOST", "https://registered-env.posthog.example"); + + const config = registerPostHogConfig({ apiKey: "registered-key" }); + vi.unstubAllEnvs(); + const resolved = Container.get(PostHogClient); + + expect(config).toEqual({ + apiKey: "registered-key", + host: "https://registered-env.posthog.example", + }); + expect(Object.isFrozen(config)).toBe(true); + expect(resolved.getClient()).not.toBeUndefined(); + expect(PostHog).toHaveBeenLastCalledWith("registered-key", { + host: "https://registered-env.posthog.example", + }); + expect(loggerMock.warn).toHaveBeenCalledOnce(); + }); + + it("should resolve with an environment host when no logger is registered", () => { + Container.reset(); + Container.register(PostHogClient, "singleton"); + vi.stubEnv("POSTHOG_HOST", "https://bootstrap.posthog.example"); + + registerPostHogConfig({ apiKey: "bootstrap-key" }); + const resolved = Container.get(PostHogClient); + + expect(resolved.getClient()).not.toBeUndefined(); + expect(PostHog).toHaveBeenLastCalledWith("bootstrap-key", { + host: "https://bootstrap.posthog.example", + }); + }); + + it("should fail with a stable DI diagnostic when configuration is not registered", () => { + Container.reset(); + Container.register(PostHogClient, "singleton"); + + const error = captureError(() => Container.get(PostHogClient)); + + expect(error).toBeInstanceOf(ContainerResolutionProblem); + expect(error).toMatchObject({ + code: "framework-context/di-resolution-failed", + reason: "missing-provider", + }); + }); + + it.each([ + ["apiKey", { apiKey: "", host: "https://valid.posthog.example" }], + ["host", { apiKey: "valid-key", host: "not-a-url" }], + ])("should reject invalid %s configuration before registration", (field, config) => { + Container.reset(); + + const configError = captureError(() => registerPostHogConfig(config)); + expect(configError).toBeInstanceOf(PostHogConfigProblem); + expect(configError).toMatchObject({ + code: "integrations-posthog/missing-config", + detail: expect.stringContaining(field), + }); + + const resolutionError = captureError(() => Container.get(POSTHOG_CONFIG_TOKEN)); + expect(resolutionError).toMatchObject({ code: "framework-context/di-resolution-failed" }); + }); + it("should throw error when host is not provided", () => { vi.unstubAllEnvs(); expect(() => new PostHogClient({ apiKey: "new-key" })).toThrow(HOST_REQUIRED_MESSAGE); diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index dc8dfa3cf..3badc6c09 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -13327,6 +13327,12 @@ ], "sourceEntrypoint": "packages/integrations-posthog/src/index.ts", "runtimeExports": [ + { + "name": "POSTHOG_CONFIG_TOKEN", + "exportKind": "named", + "source": "./libs/PostHogConfig", + "declarationKind": "const" + }, { "name": "PostHogClient", "exportKind": "named", @@ -13338,6 +13344,12 @@ "exportKind": "named", "source": "./libs/problems/PostHogProblems", "declarationKind": "class" + }, + { + "name": "registerPostHogConfig", + "exportKind": "named", + "source": "./libs/PostHogConfig", + "declarationKind": "function" } ], "typeExports": [