Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
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
1 change: 1 addition & 0 deletions packages/agent/src/adapters/acp-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ function createClaudeConnection(config: AcpConnectionConfig): AcpConnection {
...config.processCallbacks,
onStructuredOutput: config.onStructuredOutput,
posthogApiConfig: resolveEnricherApiConfig(config),
logger: config.logger?.child("ClaudeAcpAgent"),
});
return agent;
}, agentStream);
Expand Down
28 changes: 28 additions & 0 deletions packages/agent/src/adapters/claude/claude-agent.logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest";
import { Logger } from "../../utils/logger";
import { ClaudeAcpAgent } from "./claude-agent";

describe("ClaudeAcpAgent logging", () => {
it("emits through the host logger from options so enrichment logs reach the host sink", () => {
const onLog = vi.fn();
const hostLogger = new Logger({
debug: true,
prefix: "[PostHog Agent]",
onLog,
});
const client = {} as unknown as AgentSideConnection;

const agent = new ClaudeAcpAgent(client, {
logger: hostLogger.child("ClaudeAcpAgent"),
});
agent.logger.info("[apm] agent enrich", { lines: 4 });

expect(onLog).toHaveBeenCalledWith(
"info",
expect.stringContaining("ClaudeAcpAgent"),
"[apm] agent enrich",
{ lines: 4 },
);
});
});
6 changes: 5 additions & 1 deletion packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ export interface ClaudeAcpAgentOptions {
onMcpServersReady?: (serverNames: string[]) => void;
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
posthogApiConfig?: PostHogAPIConfig;
// Host log sink; without it the agent's enrichment logs never reach main.log.
logger?: Logger;
}

export class ClaudeAcpAgent extends BaseAcpAgent {
Expand All @@ -255,7 +257,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
this.options = options;
this.toolUseCache = {};
this.toolUseStreamCache = new Map();
this.logger = new Logger({ debug: true, prefix: "[ClaudeAcpAgent]" });
this.logger =
options?.logger ??
new Logger({ debug: true, prefix: "[ClaudeAcpAgent]" });
this.enrichment = createEnrichment(options?.posthogApiConfig, this.logger);
}

Expand Down
139 changes: 138 additions & 1 deletion packages/agent/src/enrichment/file-enricher.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SpanLineStat } from "@posthog/shared";
import { describe, expect, test, vi } from "vitest";
import { enrichFileForAgent, type FileEnrichmentDeps } from "./file-enricher";

Expand All @@ -10,13 +11,15 @@ function makeDeps(overrides: {
getApiKey?: () => string | Promise<string>;
findImportsInSource?: () => Promise<unknown[]>;
getWrappersForFile?: () => Promise<unknown[]>;
fetchApmLineStats?: () => Promise<SpanLineStat[]>;
}): {
deps: FileEnrichmentDeps;
parseSpy: ReturnType<typeof vi.fn>;
enrichFromApiSpy: ReturnType<typeof vi.fn>;
getApiKeySpy: ReturnType<typeof vi.fn>;
findImportsSpy: ReturnType<typeof vi.fn>;
getWrappersSpy: ReturnType<typeof vi.fn>;
fetchApmSpy: ReturnType<typeof vi.fn>;
} {
const enrichFromApiSpy = vi.fn(async () => ({
toInlineComments: () =>
Expand All @@ -39,6 +42,7 @@ function makeDeps(overrides: {
const getWrappersSpy = vi.fn(
overrides.getWrappersForFile ?? (async () => []),
);
const fetchApmSpy = vi.fn(overrides.fetchApmLineStats ?? (async () => []));

const deps: FileEnrichmentDeps = {
enricher: {
Expand All @@ -52,6 +56,8 @@ function makeDeps(overrides: {
projectId: 1,
getApiKey: getApiKeySpy,
},
apmStatsCache: new Map(),
fetchApmLineStats: fetchApmSpy,
};

return {
Expand All @@ -61,6 +67,7 @@ function makeDeps(overrides: {
getApiKeySpy,
findImportsSpy,
getWrappersSpy,
fetchApmSpy,
};
}

Expand Down Expand Up @@ -261,7 +268,12 @@ describe("enrichFileForAgent", () => {
});

test("returns null and logs debug when enricher throws", async () => {
const logger = { debug: vi.fn() };
const logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const { deps } = makeDeps({ parseRejects: new Error("boom") });
deps.logger = logger as unknown as FileEnrichmentDeps["logger"];
const result = await enrichFileForAgent(
Expand Down Expand Up @@ -295,3 +307,128 @@ describe("enrichFileForAgent", () => {
);
});
});

describe("APM enrichment caching", () => {
const ONE_HOT_LINE: SpanLineStat[] = [
{ line: 1, count: 5, errorCount: 0, p50Ms: 2, p95Ms: 7 },
];

test.each<{
name: string;
fetchApmLineStats: () => Promise<SpanLineStat[]>;
calls: number;
contains: string | null;
}>([
{
name: "serves hot stats from cache on re-read (one fetch)",
fetchApmLineStats: async () => ONE_HOT_LINE,
calls: 1,
contains: "[PostHog] APM",
},
{
name: "caches an empty result so an untraced file is fetched once",
fetchApmLineStats: async () => [],
calls: 1,
contains: null,
},
{
name: "does not cache a transient fetch failure (retries next read)",
fetchApmLineStats: async () => {
throw new Error("network down");
},
calls: 2,
contains: null,
},
])("$name", async ({ fetchApmLineStats, calls, contains }) => {
const { deps, fetchApmSpy } = makeDeps({ fetchApmLineStats });

const first = await enrichFileForAgent(deps, "/repo/flag.rs", "fn m() {}");
const second = await enrichFileForAgent(deps, "/repo/flag.rs", "fn m() {}");

for (const result of [first, second]) {
if (contains === null) expect(result).toBeNull();
else expect(result).toContain(contains);
}
expect(fetchApmSpy).toHaveBeenCalledTimes(calls);
});

test("re-applies cached stats to the current content after an edit", async () => {
const { deps } = makeDeps({ fetchApmLineStats: async () => ONE_HOT_LINE });

await enrichFileForAgent(deps, "/repo/flag.rs", "fn m() {}");
const edited = await enrichFileForAgent(
deps,
"/repo/flag.rs",
"fn renamed() {}",
);

const firstLine = edited?.split("\n")[0] ?? "";
expect(firstLine).toContain("fn renamed() {}");
expect(firstLine).toContain("[PostHog] APM");
});

test("a rejecting getApiKey degrades to null instead of throwing", async () => {
const { deps, fetchApmSpy } = makeDeps({
getApiKey: () => Promise.reject(new Error("token refresh failed")),
});

const result = await enrichFileForAgent(deps, "/repo/flag.rs", "fn m() {}");

expect(result).toBeNull();
expect(fetchApmSpy).not.toHaveBeenCalled();
});

test("queries APM with a timeout that covers the 24h window's worst case", async () => {
const { deps, fetchApmSpy } = makeDeps({
fetchApmLineStats: async () => ONE_HOT_LINE,
});

await enrichFileForAgent(deps, "/repo/flag.rs", "fn m() {}");

const config = fetchApmSpy.mock.calls[0]?.[0] as { timeoutMs?: number };
expect(config?.timeoutMs).toBeGreaterThanOrEqual(15_000);
});

test("serves from cache within the TTL, refetches once it expires", async () => {
const { deps, fetchApmSpy } = makeDeps({
fetchApmLineStats: async () => ONE_HOT_LINE,
});
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
try {
await enrichFileForAgent(deps, "/repo/flag.rs", "fn m() {}");
expect(fetchApmSpy).toHaveBeenCalledTimes(1);
now.mockReturnValue(1_000 + 4 * 60_000);
await enrichFileForAgent(deps, "/repo/flag.rs", "fn m() {}");
expect(fetchApmSpy).toHaveBeenCalledTimes(1);
now.mockReturnValue(1_000 + 5 * 60_000 + 1);
await enrichFileForAgent(deps, "/repo/flag.rs", "fn m() {}");
expect(fetchApmSpy).toHaveBeenCalledTimes(2);
} finally {
now.mockRestore();
}
});

test("an event-branch failure does not break the read or lose APM", async () => {
const { deps } = makeDeps({
fetchApmLineStats: async () => ONE_HOT_LINE,
findImportsInSource: async () => [
{
localName: "track",
importedName: "track",
resolvedAbsPath: "/repo/t.ts",
},
],
getWrappersForFile: async () => {
throw new Error("fs boom");
},
});

const out = await enrichFileForAgent(
deps,
"/repo/app.ts",
'import { track } from "./t";\ntrack("x");',
);

expect(out).toContain("[PostHog] APM");
});
});
Loading
Loading