Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/posthog-explicit-di-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@croco/integrations-posthog": patch
---

Make `PostHogClient` resolvable through Croco DI after validated configuration registration.
Original file line number Diff line number Diff line change
@@ -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/)\>

컨테이너에 등록된 동결 설정입니다.
Original file line number Diff line number Diff line change
@@ -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에 등록하고 조회할 때 사용하는 토큰입니다.
27 changes: 26 additions & 1 deletion packages/integrations-posthog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/integrations-posthog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
36 changes: 13 additions & 23 deletions packages/integrations-posthog/src/libs/PostHogClient.ts
Original file line number Diff line number Diff line change
@@ -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, {
Expand Down
64 changes: 64 additions & 0 deletions packages/integrations-posthog/src/libs/PostHogConfig.ts
Original file line number Diff line number Diff line change
@@ -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<Readonly<PostHogConfig>>("PostHogConfig");

/**
* PostHog 설정을 검증하고 환경 기반 host를 정규화한 뒤 Croco DI에 등록합니다.
*
* @param config - 등록할 PostHog API key와 선택적 HTTP(S) host입니다.
* @returns 컨테이너에 등록된 동결 설정입니다.
*/
export function registerPostHogConfig(config: PostHogConfig): Readonly<PostHogConfig> {
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;
}
98 changes: 97 additions & 1 deletion packages/integrations-posthog/src/tests/PostHogClient.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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<typeof vi.fn> };
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions public-api-surface.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -13338,6 +13344,12 @@
"exportKind": "named",
"source": "./libs/problems/PostHogProblems",
"declarationKind": "class"
},
{
"name": "registerPostHogConfig",
"exportKind": "named",
"source": "./libs/PostHogConfig",
"declarationKind": "function"
}
],
"typeExports": [
Expand Down
Loading