analytics

This commit is contained in:
johnyeo
2026-06-04 15:06:56 +01:00
parent 10bb58104c
commit 1707a7773b
53 changed files with 1726 additions and 238 deletions

2
ai

Submodule ai updated: a84bc7418c...0e52f71fbd

View File

@@ -10,6 +10,7 @@
"ts": "tsc --noEmit"
},
"dependencies": {
"@autumn/logging": "workspace:*",
"@autumn/mcp": "workspace:*",
"@autumn/shared": "workspace:*",
"@chat-adapter/slack": "^4.29.0",

View File

@@ -1,13 +1,12 @@
import type { AutumnLogger } from "@autumn/logging";
import { AppEnv } from "@autumn/shared";
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
import {
createAutumnMcpClient,
getAutumnMcpTools,
} from "./mcp.js";
import { createFirecrawlTools } from "./firecrawl.js";
import { env as chatEnv } from "../lib/env.js";
import { logger as rootLogger } from "../lib/logger.js";
import type { ChatContextMessage } from "../types.js";
import { createFirecrawlTools } from "./firecrawl.js";
import { createAutumnMcpClient, getAutumnMcpTools } from "./mcp.js";
const docs = [
"autumn://docs/tool-composition",
@@ -37,15 +36,25 @@ const recentMessageContext = (messages: ChatContextMessage[] = []) =>
}));
export const selectChatEnv = async ({
logger = rootLogger,
message,
recentMessages,
select,
}: {
logger?: AutumnLogger;
message: string;
recentMessages?: ChatContextMessage[];
select?: () => Promise<unknown> | unknown;
}) => {
if (select) return envSelectionSchema.parse(await select()).env;
if (select) {
const env = envSelectionSchema.parse(await select()).env;
logger.debug("Selected chat environment from override", {
event: "leaf.chat_env_selected",
context: { env },
data: { source: "override" },
});
return env;
}
const agent = new Agent({
id: "autumn-chat-env",
@@ -61,9 +70,12 @@ export const selectChatEnv = async ({
instructions:
"Return live unless the latest user request clearly asks to use sandbox or test mode.",
},
context: [
...recentMessageContext(recentMessages),
],
context: [...recentMessageContext(recentMessages)],
});
logger.debug("Selected chat environment from model", {
event: "leaf.chat_env_selected",
context: { env: output.object.env },
data: { source: "model" },
});
return output.object.env;
};
@@ -86,6 +98,7 @@ const readDocs = async (mcp: ReturnType<typeof createAutumnMcpClient>) => {
export const runChatAgent = async ({
apiKey,
env,
logger = rootLogger,
message,
threadId,
resourceId,
@@ -95,6 +108,7 @@ export const runChatAgent = async ({
}: {
apiKey: string;
env: AppEnv;
logger?: AutumnLogger;
message: string;
onAction?: (message: string) => Promise<void> | void;
threadId: string;
@@ -111,10 +125,22 @@ export const runChatAgent = async ({
}
| undefined;
try {
logger.info("Starting chat agent", {
event: "leaf.agent_started",
context: {
env,
org_id: resourceId,
provider,
},
data: {
thread_id: threadId,
},
});
await onAction?.("Loading Autumn tools and guidance");
const [tools, docsText] = await Promise.all([
getAutumnMcpTools(mcp, {
applyApprovalPolicy: true,
logger,
onToolCall: onAction,
onPreview: (approval) => {
previewApproval = approval;
@@ -150,8 +176,19 @@ export const runChatAgent = async ({
...recentMessageContext(recentMessages),
],
});
logger.info("Completed chat agent", {
event: "leaf.agent_completed",
context: { env },
data: {
finish_reason: output.finishReason,
run_id: output.runId,
},
});
return { ...output, env, previewApproval };
} finally {
await mcp.disconnect();
logger.debug("Disconnected Autumn MCP client", {
event: "leaf.mcp_client_disconnected",
});
}
};

View File

@@ -1,6 +1,8 @@
import type { AutumnLogger } from "@autumn/logging";
import { MCPClient } from "@mastra/mcp";
import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
import { env } from "../lib/env.js";
import { logger as rootLogger } from "../lib/logger.js";
import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
type AutumnTool = {
execute?: (
@@ -14,6 +16,7 @@ type AutumnTool = {
type ToolOptions = {
applyApprovalPolicy?: boolean;
logger?: AutumnLogger;
onToolCall?: (message: string) => Promise<void> | void;
onPreview?: (approval: {
toolName: string;
@@ -77,12 +80,25 @@ export const getAutumnMcpTools = async (
mcp: MCPClient,
options: ToolOptions = {},
) => {
const logger = options.logger ?? rootLogger;
const { toolsets, errors } = await mcp.listToolsetsWithErrors();
if (Object.keys(errors).length) {
throw new Error(`Could not load Autumn MCP tools: ${JSON.stringify(errors)}`);
logger.error("Could not load Autumn MCP tools", {
event: "leaf.mcp_tools_load_failed",
data: { errors },
});
throw new Error(
`Could not load Autumn MCP tools: ${JSON.stringify(errors)}`,
);
}
const tools = (toolsets.autumn ?? {}) as Record<string, AutumnTool>;
logger.info("Loaded Autumn MCP tools", {
event: "leaf.mcp_tools_loaded",
data: {
tool_count: Object.keys(tools).length,
},
});
for (const [toolName, tool] of Object.entries(tools)) {
if (options.applyApprovalPolicy) {
tool.requireApproval = tool.mcp?.annotations?.destructiveHint === true;
@@ -91,10 +107,21 @@ export const getAutumnMcpTools = async (
if (tool.execute && (options.onToolCall || options.onPreview)) {
const execute = tool.execute.bind(tool);
tool.execute = async (args, ...rest) => {
logger.info("Calling Autumn MCP tool", {
event: "leaf.mcp_tool_called",
tool: toolName,
});
await options.onToolCall?.(formatToolAction(toolName, args));
const result = await execute(args, ...rest);
const writeTool = getWriteToolForPreview(toolName);
if (writeTool) {
logger.info("Captured Autumn MCP preview", {
event: "leaf.mcp_preview_captured",
tool: writeTool,
data: {
preview_tool: toolName,
},
});
options.onPreview?.({
toolName: writeTool,
toolArgs: args,

View File

@@ -1,6 +1,7 @@
import { runChatAgent, selectChatEnv } from "./agent.js";
import { logger as rootLogger } from "../lib/logger.js";
import { getInstallationKey } from "../providers/slack/installations.js";
import { agentOutputSchema, type BotMessage } from "../types.js";
import { runChatAgent, selectChatEnv } from "./agent.js";
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
new Promise<T>((resolve, reject) => {
@@ -13,6 +14,7 @@ const withTimeout = <T>(promise: Promise<T>, ms: number) =>
export const runMessage = async ({
installation,
logger = rootLogger,
onAction,
recentMessages,
text,
@@ -23,11 +25,21 @@ export const runMessage = async ({
const env = await selectChatEnv({
message: text,
recentMessages,
logger,
});
logger.info("Selected chat environment", {
event: "leaf.chat_env_selected",
context: {
env,
org_id: installation.org_id,
provider: installation.provider,
},
});
return agentOutputSchema.parse(
await runChatAgent({
apiKey: getInstallationKey(installation, env),
env,
logger,
message: text,
onAction,
threadId,

View File

@@ -1,5 +1,16 @@
import type { AutumnLogger } from "@autumn/logging";
import type { ChatApproval, ChatInstallation } from "@autumn/shared";
import type { ActionEvent } from "chat";
import { toolLabel } from "../agent/toolPolicy.js";
import { logger as rootLogger } from "../lib/logger.js";
import type { AgentOutput } from "../types.js";
import { approvalCard, approvalStatusCard } from "../ui/blocks.js";
import {
finishLoading,
type LoadingState,
type ReplyTarget,
} from "../ui/progress.js";
import { approvalRequestFromOutput } from "./request.js";
import {
approveAndRun,
cancelApproval,
@@ -7,21 +18,13 @@ import {
getApproval,
isErrorResult,
} from "./store.js";
import { approvalRequestFromOutput } from "./request.js";
import { approvalCard, approvalStatusCard } from "../ui/blocks.js";
import {
finishLoading,
type LoadingState,
type ReplyTarget,
} from "../ui/progress.js";
import { toolLabel } from "../agent/toolPolicy.js";
import type { AgentOutput } from "../types.js";
export const postApprovalRequest = async ({
channelId,
installation,
loading,
logAction,
logger = rootLogger,
output,
providerUserId,
target,
@@ -30,6 +33,7 @@ export const postApprovalRequest = async ({
installation: ChatInstallation;
loading: LoadingState;
logAction: (message: string) => Promise<void> | void;
logger?: AutumnLogger;
output: AgentOutput;
providerUserId: string;
target: ReplyTarget;
@@ -47,6 +51,15 @@ export const postApprovalRequest = async ({
});
await logAction(`Waiting for approval: ${toolLabel(approval.toolName)}`);
logger.info("Created approval request", {
event: "leaf.approval_created",
context: {
env: approval.env,
org_id: installation.org_id,
},
approval_id: approvalId,
tool: approval.toolName,
});
await finishLoading(target, loading, "Preview ready.");
await target.post(
approvalCard({
@@ -92,10 +105,22 @@ export const handleApprovalAction = async (event: ActionEvent) => {
if (!event.value) return;
try {
rootLogger.info("Received approval action", {
event: "leaf.approval_action_received",
approval_id: event.value,
action: event.actionId,
data: {
provider_user_id: event.user.userId,
},
});
const details = await approvalDetails(event.value);
if (event.actionId === "cancel_billing_action") {
const cancelled = await cancelApproval(event.value, event.user.userId);
if (!cancelled) {
rootLogger.warn("Approval cancellation ignored", {
event: "leaf.approval_cancel_ignored",
approval_id: event.value,
});
const current = await getApproval(event.value);
await editActionMessage(
event,
@@ -110,6 +135,11 @@ export const handleApprovalAction = async (event: ActionEvent) => {
event,
approvalStatusCard({ status: "cancelled", ...details }),
);
rootLogger.info("Cancelled approval", {
event: "leaf.approval_cancelled",
approval_id: event.value,
tool: details.toolName,
});
return;
}
@@ -118,6 +148,12 @@ export const handleApprovalAction = async (event: ActionEvent) => {
approvalStatusCard({ status: "running", ...details }),
);
const result = await approveAndRun(event.value, event.user.userId);
rootLogger.info("Completed approval action", {
event: "leaf.approval_completed",
approval_id: event.value,
status: isErrorResult(result) ? "failed" : "approved",
tool: details.toolName,
});
await editActionMessage(
event,
approvalStatusCard({
@@ -127,7 +163,11 @@ export const handleApprovalAction = async (event: ActionEvent) => {
}),
);
} catch (error) {
console.error("[chat] Approval action failed", error);
rootLogger.error("[chat] Approval action failed", error, {
event: "leaf.approval_failed",
approval_id: event.value,
action: event.actionId,
});
const current = await getApproval(event.value);
await editActionMessage(
event,

View File

@@ -2,12 +2,19 @@ import { createSlackAdapter } from "@chat-adapter/slack";
import { createPostgresState } from "@chat-adapter/state-pg";
import type { Message, Thread } from "chat";
import { Chat } from "chat";
import { runMessage } from "./agent/messages.js";
import { handleApprovalAction, postApprovalRequest } from "./approvals/flow.js";
import { getSlackWorkspaceId } from "./providers/slack/context.js";
import { decrypt } from "./lib/crypto.js";
import { env } from "./lib/env.js";
import {
addLeafContext,
createLeafSessionContext,
logger as rootLogger,
} from "./lib/logger.js";
import { getSlackWorkspaceId } from "./providers/slack/context.js";
import { findInstallation } from "./providers/slack/installations.js";
import { runMessage } from "./agent/messages.js";
import { getRecentMessages } from "./providers/slack/threadContext.js";
import type { ChatContextMessage } from "./types.js";
import {
createActionLogger,
finishLoading,
@@ -15,8 +22,6 @@ import {
type ReplyTarget,
startLoading,
} from "./ui/progress.js";
import { getRecentMessages } from "./providers/slack/threadContext.js";
import type { ChatContextMessage } from "./types.js";
export const chatAdapterNames = ["slack"];
@@ -66,15 +71,46 @@ const runAndReply = async ({
threadId: string;
}) => {
let loading: LoadingState = null;
let logger = rootLogger;
try {
const workspaceId = getSlackWorkspaceId(raw);
const session = createLeafSessionContext({
channelId,
provider: "slack",
providerUserId,
threadId,
workspaceId,
});
logger = addLeafContext(rootLogger, {
...session.context,
agent_run_id: session.agentRunId,
});
logger.info("Received Slack message", {
event: "leaf.slack_message_received",
data: {
text_length: text.length,
},
});
const installation = await findInstallation("slack", workspaceId);
if (!installation || !text.trim()) return;
if (!installation) {
logger.warn("Slack installation not found", {
event: "leaf.slack_installation_missing",
});
return;
}
if (!text.trim()) {
logger.info("Skipping empty Slack message", {
event: "leaf.slack_message_skipped",
data: { reason: "empty" },
});
return;
}
loading = await startLoading(target);
const logAction = createActionLogger(loading);
const output = await runMessage({
installation,
logger,
onAction: logAction,
recentMessages,
text,
@@ -86,6 +122,7 @@ const runAndReply = async ({
installation,
loading,
logAction,
logger,
output,
providerUserId,
target,
@@ -94,8 +131,16 @@ const runAndReply = async ({
await finishLoading(target, loading, "Done.");
await target.post({ markdown: output.text || "Done." });
logger.info("Posted Slack response", {
event: "leaf.slack_response_posted",
data: {
has_text: Boolean(output.text),
},
});
} catch (error) {
console.error("[chat] Message failed", error);
logger.error("[chat] Message failed", error, {
event: "leaf.slack_message_failed",
});
await finishLoading(target, loading, "Request failed.");
await target.post({
markdown: "I could not complete that request. Please try again.",

View File

@@ -0,0 +1,60 @@
import {
type AutumnLogger,
createAppLogger,
createSessionId,
createTraceId,
} from "@autumn/logging";
export const logger = createAppLogger({
service: "leaf",
dataset: process.env.LEAF_LOG_DATASET ?? "leaf",
preset: "default",
});
export const createLeafSessionContext = ({
channelId,
provider,
providerUserId,
threadId,
workspaceId,
}: {
channelId: string;
provider: string;
providerUserId: string;
threadId: string;
workspaceId: string;
}) => {
const traceId = createTraceId();
const sessionId = createSessionId({
parts: {
channelId,
provider,
threadId,
workspaceId,
},
});
return {
agentRunId: createTraceId(),
sessionId,
traceId,
context: {
provider,
provider_user_id: providerUserId,
session_id: sessionId,
trace_id: traceId,
slack_channel_id: channelId,
slack_thread_id: threadId,
slack_workspace_id: workspaceId,
},
};
};
export const addLeafContext = (
baseLogger: AutumnLogger,
context: Record<string, unknown>,
): AutumnLogger =>
baseLogger.child({
context: {
context,
},
});

View File

@@ -1,9 +1,9 @@
import { createConsoleLogger } from "@autumn/mcp";
import type { HttpBindings } from "@hono/node-server";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { chatAdapterNames } from "./bot.js";
import { env } from "./lib/env.js";
import { logger } from "./lib/logger.js";
import { registerMcpRoutes } from "./mcp/http.js";
import { slackRoutes } from "./providers/slack/routes.js";
@@ -22,7 +22,7 @@ registerMcpRoutes(app, {
"oauth-enabled": true,
"oauth-environment": env.MCP_OAUTH_ENVIRONMENT,
"server-url": env.BETTER_AUTH_URL,
logger: createConsoleLogger("info"),
logger,
});
app.route("/slack", slackRoutes);
@@ -34,9 +34,12 @@ serve(
port: env.PORT,
},
({ address, port }) => {
console.log("Chat listening", {
host: `${address}:${port}`,
adapters: chatAdapterNames,
logger.info("Chat listening", {
event: "leaf.server_started",
data: {
host: `${address}:${port}`,
adapters: chatAdapterNames,
},
});
},
);

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import type { AutumnLogger } from "@autumn/logging";
import {
buildAuthForRequest,
type ConsoleLogger,
createAutumnOperationsMCPServer,
getAuthorizationServerMetadata,
getProtectedResourceMetadata,
@@ -15,7 +16,7 @@ import type { Context, Hono } from "hono";
export interface McpRouteOptions extends MCPServerFlags {
readonly "oauth-enabled": boolean;
readonly "oauth-environment": OAuthEnvironment;
readonly logger: ConsoleLogger;
readonly logger: AutumnLogger;
}
type AppContext = Context<{ Bindings: HttpBindings }>;
@@ -23,6 +24,8 @@ type McpPath = "/mcp";
type McpApp = Hono<{ Bindings: HttpBindings }>;
export function registerMcpRoutes(app: McpApp, options: McpRouteOptions) {
const mcpServer = createAutumnOperationsMCPServer();
app.get("/.well-known/oauth-protected-resource/mcp", (c) =>
c.json(getProtectedResourceMetadata(c.req.raw.headers, options, "/mcp")),
);
@@ -64,14 +67,12 @@ export function registerMcpRoutes(app: McpApp, options: McpRouteOptions) {
httpPath: path,
req: c.env.incoming,
res: c.env.outgoing,
options: { serverless: true },
options: { sessionIdGenerator: randomUUID },
});
return RESPONSE_ALREADY_SENT;
};
app.all("/mcp", (c) =>
handleMcp(c, "/mcp", createAutumnOperationsMCPServer()),
);
app.all("/mcp", (c) => handleMcp(c, "/mcp", mcpServer));
return app;
}

View File

@@ -2,6 +2,7 @@ import { verifyChatInstallState } from "@autumn/shared/utils/chatState";
import { Hono } from "hono";
import { z } from "zod";
import { bot } from "../../bot.js";
import { logger } from "../../lib/logger.js";
import { getStateSecret, replaceInstallation } from "./installations.js";
import { exchangeSlackCode, slackErrorUrl, slackSuccessUrl } from "./oauth.js";
@@ -36,25 +37,38 @@ slackRoutes.get("/oauth/callback", async (c) => {
.filter(Boolean),
installedByProviderUserId: oauth.authed_user?.id,
});
console.info("[chat:slack] Installed", {
orgId: parsedState.orgId,
workspaceId: oauth.team.id,
workspaceName: oauth.team.name,
logger.info("[chat:slack] Installed", {
event: "leaf.slack_installed",
context: {
org_id: parsedState.orgId,
slack_workspace_id: oauth.team.id,
},
data: {
workspace_name: oauth.team.name,
},
});
return c.redirect(slackSuccessUrl());
} catch (error) {
console.error("[chat:slack] OAuth callback failed", error);
logger.error("[chat:slack] OAuth callback failed", error, {
event: "leaf.slack_oauth_failed",
});
return c.redirect(slackErrorUrl("Slack install failed"));
}
});
slackRoutes.post("/events", (c) => {
logger.debug("Received Slack events request", {
event: "leaf.slack_events_request_received",
});
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
return bot.webhooks.slack(c.req.raw);
});
slackRoutes.post("/interactions", (c) => {
logger.debug("Received Slack interactions request", {
event: "leaf.slack_interactions_request_received",
});
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
return bot.webhooks.slack(c.req.raw);
});

View File

@@ -1,4 +1,5 @@
import { AppEnv, type ChatInstallation } from "@autumn/shared";
import type { AutumnLogger } from "@autumn/logging";
import { z } from "zod";
export const agentOutputSchema = z.preprocess(
@@ -62,6 +63,7 @@ export type SignatureArgs = {
export type BotMessage = {
installation: ChatInstallation;
logger?: AutumnLogger;
onAction?: (message: string) => Promise<void> | void;
recentMessages?: ChatContextMessage[];
text: string;

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import { createLeafSessionContext } from "../../../src/lib/logger.js";
describe("Leaf logger context", () => {
test("creates stable session ids and distinct trace ids", () => {
const first = createLeafSessionContext({
channelId: "C1",
provider: "slack",
providerUserId: "U1",
threadId: "T1",
workspaceId: "W1",
});
const second = createLeafSessionContext({
channelId: "C1",
provider: "slack",
providerUserId: "U2",
threadId: "T1",
workspaceId: "W1",
});
expect(first.sessionId).toBe(second.sessionId);
expect(first.traceId).not.toBe(second.traceId);
expect(first.context).toMatchObject({
provider: "slack",
session_id: first.sessionId,
trace_id: first.traceId,
slack_channel_id: "C1",
slack_thread_id: "T1",
slack_workspace_id: "W1",
});
});
});

View File

@@ -12,6 +12,7 @@
"paths": {
"@autumn/shared": ["../../shared/index.ts"],
"@autumn/shared/*": ["../../shared/*"],
"@autumn/logging": ["../../packages/logging/src/index.ts"],
"@autumn/mcp/*": ["../../packages/mcp/*"],
"@api/*": ["../../shared/api/*"],
"@models/*": ["../../shared/models/*"],

View File

@@ -88,6 +88,7 @@
"name": "@autumn/leaf",
"version": "0.0.1",
"dependencies": {
"@autumn/logging": "workspace:*",
"@autumn/mcp": "workspace:*",
"@autumn/shared": "workspace:*",
"@chat-adapter/slack": "^4.29.0",
@@ -284,10 +285,24 @@
"name": "@autumn/ksuid",
"version": "1.0.0",
},
"packages/logging": {
"name": "@autumn/logging",
"version": "0.0.1",
"dependencies": {
"@axiomhq/pino": "^1.3.1",
"pino": "^9.6.0",
},
"devDependencies": {
"@types/bun": "^1.2.13",
"@types/node": "^18.19.3",
"typescript": "~5.8.3",
},
},
"packages/mcp": {
"name": "@autumn/mcp",
"version": "0.0.1",
"dependencies": {
"@autumn/logging": "workspace:*",
"@autumn/shared": "workspace:*",
"@axiomhq/js": "^1.6.1",
"@mastra/core": "^1.36.0",
@@ -722,6 +737,8 @@
"@autumn/leaf": ["@autumn/leaf@workspace:apps/leaf"],
"@autumn/logging": ["@autumn/logging@workspace:packages/logging"],
"@autumn/mcp": ["@autumn/mcp@workspace:packages/mcp"],
"@autumn/openapi": ["@autumn/openapi@workspace:packages/openapi"],
@@ -6000,6 +6017,10 @@
"@autumn/leaf/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"@autumn/logging/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@autumn/logging/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@autumn/mcp/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@autumn/mcp/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
@@ -7770,6 +7791,8 @@
"@autumn/leaf/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"@autumn/logging/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@autumn/mcp/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@autumn/server/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],

View File

@@ -29,6 +29,7 @@ COPY packages/atmn/package.json packages/atmn/
COPY packages/atmn-tests/package.json packages/atmn-tests/
COPY packages/autumn-js/package.json packages/autumn-js/
COPY packages/ksuid/package.json packages/ksuid/
COPY packages/logging/package.json packages/logging/
COPY packages/mcp/package.json packages/mcp/
COPY packages/openapi/package.json packages/openapi/
COPY packages/sdk/package.json packages/sdk/

View File

@@ -1,45 +0,0 @@
# Multi-stage Dockerfile for Autumn development
FROM oven/bun:latest AS base
WORKDIR /app
# Skip Puppeteer Chromium download to speed up install
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_SKIP_DOWNLOAD=true
COPY package.json ./
COPY bun.lock ./
COPY shared/package*.json ./shared/
COPY server/package*.json ./server/
COPY vite/package*.json ./vite/
RUN bun install
# Stage 1: /localtunnel
FROM base AS localtunnel
WORKDIR /app
COPY localtunnel-start.sh ./
CMD ["sh", "localtunnel-start.sh"]
# Stage 2: /vite
FROM base AS vite
COPY shared/ ./shared/
WORKDIR /app/vite
COPY vite/ ./
EXPOSE 3000
CMD ["bun", "dev"]
# Stage 3: /server
FROM base AS server
COPY shared/ ./shared/
COPY server/ ./server/
WORKDIR /app/server
EXPOSE 8080
CMD ["bun", "dev"]
# Stage 4: Workers
FROM base AS workers
COPY shared/ ./shared/
COPY server/ ./server/
WORKDIR /app/server
CMD ["bun", "workers:dev"]

View File

@@ -15,6 +15,7 @@
"apps/sdk-test",
"packages/atmn",
"packages/atmn-tests",
"packages/logging",
"packages/mcp",
"packages/sdk",
"packages/autumn-js",
@@ -115,6 +116,7 @@
"tb:prod-legacy": "bun scripts/tinybird/index.ts prod-legacy",
"axiom": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/axiom/cli.ts",
"axiom:prod": "ENV_FILE=.env.prod infisical run --env=prod --recursive -- bun scripts/axiom/cli.ts",
"add-mcp": "bun scripts/mcp/addMcp.ts",
"trigger:deploy": "bunx trigger.dev deploy",
"setupci": "node scripts/setup/setupci.js",
"replicate": "bun scripts/db/replicate.ts",

View File

@@ -0,0 +1,30 @@
{
"name": "@autumn/logging",
"version": "0.0.1",
"author": "Autumn",
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts"
},
"files": [
"README.md",
"src"
],
"scripts": {
"build": "tsc",
"ts": "tsc --noEmit",
"test": "bun test tests/unit",
"prepack": "bun run build",
"prepublishOnly": "bun run build"
},
"dependencies": {
"@axiomhq/pino": "^1.3.1",
"pino": "^9.6.0"
},
"devDependencies": {
"@types/bun": "^1.2.13",
"@types/node": "^18.19.3",
"typescript": "~5.8.3"
}
}

View File

@@ -0,0 +1,38 @@
import type { AutumnLogger } from "../types.js";
import type {
LogAppContext,
LogRequestContext,
LogTriggerContext,
} from "./types.js";
export const addRequestToLogs = ({
logger,
requestContext,
}: {
logger: AutumnLogger;
requestContext: LogRequestContext;
}): AutumnLogger => logger.child({ context: { req: requestContext } });
export const addAppContextToLogs = ({
logger,
appContext,
}: {
logger: AutumnLogger;
appContext: LogAppContext;
}): AutumnLogger => logger.child({ context: { context: appContext } });
export const addTriggerToLogs = ({
logger,
triggerContext,
}: {
logger: AutumnLogger;
triggerContext: LogTriggerContext;
}): AutumnLogger => logger.child({ context: { trigger: triggerContext } });
export const addExtrasToLogs = ({
logger,
extras,
}: {
logger: AutumnLogger;
extras: Record<string, unknown>;
}): AutumnLogger => logger.child({ context: { extras } });

View File

@@ -0,0 +1,35 @@
export type LogRequestContext = {
id: string;
method: string;
url: string;
timestamp: number;
customer_id?: string;
entity_id?: string;
user_agent?: string;
ip_address?: string;
region?: string;
query: Record<string, string>;
body: unknown;
name: string;
};
export type LogAppContext = {
org_id?: string;
org_slug?: string;
env?: string;
auth_type?: string;
customer_id?: string;
entity_id?: string;
user_id?: string;
user_email?: string;
api_version?: string;
scopes?: string[];
full_subject_bucket?: number;
full_subject_rollout_enabled?: boolean;
};
export type LogTriggerContext = {
run_id: string;
task_id: string;
attempt_number?: number;
};

View File

@@ -0,0 +1,21 @@
import { createHash } from "node:crypto";
const stableStringify = ({ value }: { value: unknown }): string => {
if (!value || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value))
return `[${value.map((item) => stableStringify({ value: item })).join(",")}]`;
return `{${Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b))
.map(
([key, item]) =>
`${JSON.stringify(key)}:${stableStringify({ value: item })}`,
)
.join(",")}}`;
};
export const createSessionId = ({ parts }: { parts: unknown }): string =>
createHash("sha256")
.update(stableStringify({ value: parts }))
.digest("hex")
.slice(0, 24);

View File

@@ -0,0 +1,3 @@
import { randomUUID } from "node:crypto";
export const createTraceId = (): string => randomUUID();

View File

@@ -0,0 +1,40 @@
export {
addAppContextToLogs,
addExtrasToLogs,
addRequestToLogs,
addTriggerToLogs,
} from "./context/addContextToLogs.js";
export type {
LogAppContext,
LogRequestContext,
LogTriggerContext,
} from "./context/types.js";
export { createSessionId } from "./ids/createSessionId.js";
export { createTraceId } from "./ids/createTraceId.js";
export {
createAppLogger,
createAutumnLogger,
} from "./logger/autumnLogger.js";
export { createConsoleLogger } from "./logger/consoleLogger.js";
export { createLogger } from "./logger/createLogger.js";
export {
mirrorLogger,
withLogPrefix,
} from "./logger/loggerWrappers.js";
export { resolveLoggerOptions } from "./logger/resolveLoggerOptions.js";
export { asAxiomMap } from "./payload/asAxiomMap.js";
export {
type GuardLogPayloadOptions,
guardLogPayload,
} from "./payload/guardLogPayload.js";
export type {
AutumnLogger,
ConsoleLogger,
ConsoleLoggerLevel,
CreateLoggerParams,
LoggerLevel,
LoggerOutput,
LoggerPreset,
PinoLogger,
ResolvedLoggerOptions,
} from "./types.js";

View File

@@ -0,0 +1,69 @@
import type pino from "pino";
import type {
AutumnLogger,
ConsoleLoggerLevel,
CreateLoggerParams,
LogArgs,
} from "../types.js";
import { createLogger } from "./createLogger.js";
const rewriteAppPath = (value: string): string =>
value.replace("file:///app/", "./").replace(/\/app\//g, "./");
const errorToObject = (error: Error) => ({
name: error.name,
message: error.message,
stack: error.stack ? rewriteAppPath(error.stack) : undefined,
});
const normalizeLogArgs = ({ args }: { args: LogArgs }) => {
const strings = args
.filter((arg): arg is string => typeof arg === "string")
.map(rewriteAppPath);
const objects = args
.filter(
(arg) => typeof arg !== "string" && arg !== null && arg !== undefined,
)
.map((arg) => (arg instanceof Error ? { error: errorToObject(arg) } : arg));
const error = args.find((arg): arg is Error => arg instanceof Error);
const message =
strings.at(-1) ??
(error
? rewriteAppPath(error.stack || error.message || "Error occurred")
: "");
return {
message,
merged: Object.assign({}, ...objects) as Record<string, unknown>,
};
};
const createLogMethod =
({ method }: { method: pino.LogFn }) =>
(...args: LogArgs) => {
const { message, merged } = normalizeLogArgs({ args });
if (Object.keys(merged).length > 0) method(merged, message);
else method(message);
};
export const createAutumnLogger = ({
logger,
}: {
logger: pino.Logger;
}): AutumnLogger => ({
level: logger.level as ConsoleLoggerLevel,
debug: createLogMethod({ method: logger.debug.bind(logger) }),
info: createLogMethod({ method: logger.info.bind(logger) }),
warn: createLogMethod({ method: logger.warn.bind(logger) }),
warning: createLogMethod({ method: logger.warn.bind(logger) }),
error: createLogMethod({ method: logger.error.bind(logger) }),
child: ({ context, onlyProd = false }) => {
if (onlyProd && process.env.NODE_ENV !== "production") {
return createAutumnLogger({ logger });
}
return createAutumnLogger({ logger: logger.child(context) });
},
});
export const createAppLogger = (params: CreateLoggerParams): AutumnLogger =>
createAutumnLogger({ logger: createLogger(params) });

View File

@@ -0,0 +1,28 @@
import type { ConsoleLogger, ConsoleLoggerLevel, LogArgs } from "../types.js";
export const createConsoleLogger = ({
level,
}: {
level: ConsoleLoggerLevel;
}): ConsoleLogger => {
const levels: ConsoleLoggerLevel[] = ["debug", "info", "warning", "error"];
const min = levels.indexOf(level);
const noop = () => {};
const log =
({ method }: { method: "debug" | "info" | "warn" | "error" }) =>
(...args: LogArgs) => {
console[method](...args);
};
const logger: ConsoleLogger = {
level,
debug: min <= 0 ? log({ method: "debug" }) : noop,
info: min <= 1 ? log({ method: "info" }) : noop,
warn: min <= 2 ? log({ method: "warn" }) : noop,
warning: min <= 2 ? log({ method: "warn" }) : noop,
error: min <= 3 ? log({ method: "error" }) : noop,
child: () => logger,
};
return logger;
};

View File

@@ -0,0 +1,60 @@
import pino from "pino";
import { createConsoleJsonStream } from "../streams/consoleJsonStream.js";
import { createPrettyLogStream } from "../streams/prettyLogStream.js";
import type { CreateLoggerParams } from "../types.js";
import { resolveLoggerOptions } from "./resolveLoggerOptions.js";
export const createLogger = (params: CreateLoggerParams): pino.Logger => {
const resolved = resolveLoggerOptions({ options: params });
const axiomToken = params.axiomToken ?? process.env.AXIOM_TOKEN;
const axiomOrgId = params.axiomOrgId ?? process.env.AXIOM_ORG_ID;
const streams: pino.StreamEntry[] = [];
for (const output of resolved.outputs) {
if (output === "console-pretty") {
streams.push({
level: resolved.level,
stream: createPrettyLogStream({
trailingNewline: resolved.preset !== "dual",
useConsoleLog: params.useConsoleLog ?? resolved.preset === "dual",
}),
});
}
if (output === "console-json") {
streams.push({
level: resolved.level,
stream: createConsoleJsonStream(),
});
}
if (output === "axiom" && axiomToken) {
streams.push({
level: resolved.level,
stream: pino.transport({
target: "@axiomhq/pino",
options: {
dataset: resolved.dataset,
token: axiomToken,
orgId: axiomOrgId,
},
}),
});
}
}
return pino(
{
level: resolved.level,
base: {
service: resolved.service,
...(params.context ?? {}),
},
mixin: params.mixin,
formatters: {
level: (label: string) => ({ level: label.toUpperCase() }),
},
},
pino.multistream(streams),
);
};

View File

@@ -0,0 +1,71 @@
import type { AutumnLogger, LogArgs } from "../types.js";
const logToStdout = ({
level,
args,
}: {
level: "debug" | "info" | "warn" | "error";
args: LogArgs;
}) => {
const method =
level === "debug"
? console.debug
: level === "info"
? console.info
: level === "warn"
? console.warn
: console.error;
method(...args);
};
export const mirrorLogger = ({
logger,
}: {
logger: AutumnLogger;
}): AutumnLogger => ({
debug: (...args) => {
logger.debug(...args);
logToStdout({ level: "debug", args });
},
info: (...args) => {
logger.info(...args);
logToStdout({ level: "info", args });
},
warn: (...args) => {
logger.warn(...args);
logToStdout({ level: "warn", args });
},
warning: (...args) => {
logger.warn(...args);
logToStdout({ level: "warn", args });
},
error: (...args) => {
logger.error(...args);
logToStdout({ level: "error", args });
},
child: (params) => mirrorLogger({ logger: logger.child(params) }),
});
const prefixArgs = ({ prefix, args }: { prefix: string; args: LogArgs }) => {
if (typeof args[0] !== "string") return [prefix, ...args];
if (args[0].startsWith(prefix)) return args;
return [`${prefix} ${args[0]}`, ...args.slice(1)];
};
export const withLogPrefix = ({
logger,
label,
}: {
logger: AutumnLogger;
label: string;
}): AutumnLogger => {
const prefix = `[${label}]`;
return {
debug: (...args) => logger.debug(...prefixArgs({ prefix, args })),
info: (...args) => logger.info(...prefixArgs({ prefix, args })),
warn: (...args) => logger.warn(...prefixArgs({ prefix, args })),
warning: (...args) => logger.warn(...prefixArgs({ prefix, args })),
error: (...args) => logger.error(...prefixArgs({ prefix, args })),
child: (params) => withLogPrefix({ logger: logger.child(params), label }),
};
};

View File

@@ -0,0 +1,67 @@
import type {
CreateLoggerParams,
LoggerLevel,
LoggerOutput,
ResolvedLoggerOptions,
} from "../types.js";
const parseOutputs = (
value: string | undefined,
): LoggerOutput[] | undefined => {
if (!value) return undefined;
const outputs = value
.split(",")
.map((part) => part.trim())
.filter(Boolean);
if (
outputs.every(
(output): output is LoggerOutput =>
output === "console-pretty" ||
output === "console-json" ||
output === "axiom",
)
) {
return outputs;
}
return undefined;
};
export const resolveLoggerOptions = ({
options,
env = process.env,
}: {
options: CreateLoggerParams;
env?: NodeJS.ProcessEnv;
}): ResolvedLoggerOptions => {
const preset = options.preset ?? "default";
const isDevOrTest = env.NODE_ENV === "development" || env.NODE_ENV === "test";
const hasAxiomToken = Boolean(options.axiomToken ?? env.AXIOM_TOKEN);
let outputs = options.outputs ?? parseOutputs(env.LOG_OUTPUTS);
if (!outputs) {
if (preset === "console-only") outputs = ["console-pretty"];
else if (preset === "axiom-only") outputs = ["axiom"];
else if (preset === "dual")
outputs = [isDevOrTest ? "console-pretty" : "console-json", "axiom"];
else if (isDevOrTest) outputs = ["console-pretty", "axiom"];
else outputs = ["axiom"];
}
const filteredOutputs = outputs.filter(
(output) => output !== "axiom" || hasAxiomToken,
);
return {
service: options.service,
dataset: options.dataset ?? options.service,
preset,
level:
options.level ??
((env.LOG_LEVEL as LoggerLevel | undefined) ||
(isDevOrTest || preset === "dual" ? "debug" : "info")),
outputs: filteredOutputs.length > 0 ? filteredOutputs : ["console-pretty"],
hasAxiomToken,
};
};

View File

@@ -0,0 +1,8 @@
export const asAxiomMap = ({
value,
}: {
value: unknown;
}): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: { value };

View File

@@ -0,0 +1,147 @@
const defaultMaxPayloadBytes = 512_000;
const defaultTruncateAboveBytes = 4_000;
const defaultMaxArrayItems = 5;
const defaultMaxStringLength = 500;
const defaultMaxDepth = 6;
export type GuardLogPayloadOptions = {
maxPayloadBytes?: number;
truncateAboveBytes?: number;
maxArrayItems?: number;
maxStringLength?: number;
maxDepth?: number;
};
const envNumber = ({
value,
fallback,
}: {
value?: string;
fallback: number;
}) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const resolveOptions = ({
options = {},
}: {
options?: GuardLogPayloadOptions;
}) => ({
maxPayloadBytes:
options.maxPayloadBytes ??
envNumber({
value: process.env.LOG_MAX_PAYLOAD_BYTES,
fallback: defaultMaxPayloadBytes,
}),
truncateAboveBytes:
options.truncateAboveBytes ??
envNumber({
value: process.env.LOG_TRUNCATE_ABOVE_BYTES,
fallback: defaultTruncateAboveBytes,
}),
maxArrayItems:
options.maxArrayItems ??
envNumber({
value: process.env.LOG_MAX_ARRAY_ITEMS,
fallback: defaultMaxArrayItems,
}),
maxStringLength:
options.maxStringLength ??
envNumber({
value: process.env.LOG_MAX_STRING_LENGTH,
fallback: defaultMaxStringLength,
}),
maxDepth: options.maxDepth ?? defaultMaxDepth,
});
type ResolvedGuardOptions = ReturnType<typeof resolveOptions>;
const truncateString = ({
value,
maxStringLength,
}: {
value: string;
maxStringLength: number;
}): string =>
value.length > maxStringLength
? `${value.slice(0, maxStringLength)}...[+${value.length - maxStringLength} chars]`
: value;
const truncateValue = ({
value,
options,
depth = 0,
}: {
value: unknown;
options: ResolvedGuardOptions;
depth?: number;
}): unknown => {
if (typeof value === "string")
return truncateString({
value,
maxStringLength: options.maxStringLength,
});
if (!value || typeof value !== "object") return value;
if (depth >= options.maxDepth) {
if (Array.isArray(value)) return `...[${value.length} items]`;
return "...[object]";
}
if (Array.isArray(value)) {
const kept = value.slice(0, options.maxArrayItems).map((item) =>
truncateValue({
value: item,
options,
depth: depth + 1,
}),
);
if (value.length > options.maxArrayItems) {
kept.push(`...[+${value.length - options.maxArrayItems} more items]`);
}
return kept;
}
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack,
};
}
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
out[key] = truncateValue({
value: item,
options,
depth: depth + 1,
});
}
return out;
};
export const guardLogPayload = ({
value,
options: guardOptions,
}: {
value: unknown;
options?: GuardLogPayloadOptions;
}): unknown => {
if (value === undefined) return undefined;
const options = resolveOptions({ options: guardOptions });
try {
const json = JSON.stringify(value);
if (!json || json.length <= options.truncateAboveBytes) return value;
const truncated = truncateValue({ value, options });
const truncatedJson = JSON.stringify(truncated);
if (truncatedJson && truncatedJson.length > options.maxPayloadBytes) {
return { _truncated: true, _bytes: truncatedJson.length };
}
return truncated;
} catch {
return { _unserializable: true };
}
};

View File

@@ -0,0 +1,9 @@
import { Writable } from "node:stream";
export const createConsoleJsonStream = () =>
new Writable({
write(chunk, _encoding, callback) {
console.log(chunk.toString().trimEnd());
callback();
},
});

View File

@@ -0,0 +1,116 @@
import { Writable } from "node:stream";
const FORMATTED_LOG_EXCLUDE_FIELDS = new Set([
"time",
"level",
"msg",
"pid",
"hostname",
"req",
"res",
"statusCode",
"body",
"query",
"durationMs",
"duration_ms",
"context",
"workflow",
"trigger",
"stripe_event",
"vercel_event",
"worker",
"extras",
"type",
"data",
"aws",
"service",
]);
const colors = {
reset: "\x1b[0m",
bright: "\x1b[1m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
white: "\x1b[37m",
gray: "\x1b[90m",
bgRed: "\x1b[41m",
};
const levelColors: Record<number | string, string> = {
10: colors.gray,
20: colors.blue,
30: colors.green,
40: colors.yellow,
50: colors.red,
60: colors.bgRed,
TRACE: colors.gray,
DEBUG: colors.blue,
INFO: colors.green,
WARN: colors.yellow,
ERROR: colors.red,
FATAL: colors.bgRed,
};
const levelNames: Record<number | string, string> = {
10: "TRACE",
20: "DEBUG",
30: "INFO",
40: "WARN",
50: "ERROR",
60: "FATAL",
TRACE: "TRACE",
DEBUG: "DEBUG",
INFO: "INFO",
WARN: "WARN",
ERROR: "ERROR",
FATAL: "FATAL",
};
export const createPrettyLogStream = ({
trailingNewline = true,
useConsoleLog = false,
}: {
trailingNewline?: boolean;
useConsoleLog?: boolean;
} = {}) =>
new Writable({
write(chunk, _encoding, callback) {
try {
const log = JSON.parse(chunk.toString());
const timestamp = new Date(log.time)
.toISOString()
.replace("T", " ")
.replace("Z", "");
const level = log.level;
const levelColor = levelColors[level] || colors.white;
const levelName =
levelNames[level] || (typeof level === "string" ? level : "UNKNOWN");
let message = log.msg || "";
const additionalFields = Object.keys(log)
.filter((key) => !FORMATTED_LOG_EXCLUDE_FIELDS.has(key))
.reduce(
(acc, key) => {
acc[key] = log[key];
return acc;
},
{} as Record<string, unknown>,
);
if (Object.keys(additionalFields).length > 0) {
message += ` ${JSON.stringify(additionalFields, null, 2)}`;
}
const formattedLog = `${colors.gray}${timestamp}${colors.reset} ${levelColor}${colors.bright}${levelName}${colors.reset} ${message}${trailingNewline ? "\n" : ""}`;
if (useConsoleLog) console.log(formattedLog);
else process.stdout.write(formattedLog);
callback();
} catch {
if (useConsoleLog) console.log(chunk.toString());
else process.stdout.write(chunk);
callback();
}
},
});

View File

@@ -0,0 +1,56 @@
import type pino from "pino";
export type LoggerOutput = "console-pretty" | "console-json" | "axiom";
export type LoggerPreset = "default" | "dual" | "console-only" | "axiom-only";
export type LoggerLevel =
| "trace"
| "debug"
| "info"
| "warn"
| "error"
| "fatal";
export type CreateLoggerParams = {
service: string;
dataset?: string;
level?: LoggerLevel;
preset?: LoggerPreset;
outputs?: LoggerOutput[];
context?: Record<string, unknown>;
mixin?: () => Record<string, unknown>;
axiomToken?: string;
axiomOrgId?: string;
useConsoleLog?: boolean;
};
export type ResolvedLoggerOptions = Required<
Pick<CreateLoggerParams, "service" | "preset">
> & {
dataset: string;
level: LoggerLevel;
outputs: LoggerOutput[];
hasAxiomToken: boolean;
};
export type LogArgs = unknown[];
export type AutumnLogger = {
level?: string;
debug: (...args: LogArgs) => void;
info: (...args: LogArgs) => void;
warn: (...args: LogArgs) => void;
warning: (...args: LogArgs) => void;
error: (...args: LogArgs) => void;
child: (params: {
context: Record<string, unknown>;
onlyProd?: boolean;
}) => AutumnLogger;
};
export type ConsoleLoggerLevel = "debug" | "info" | "warning" | "error";
export type ConsoleLogger = AutumnLogger & {
level: ConsoleLoggerLevel;
};
export type PinoLogger = pino.Logger;

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"allowJs": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"checkJs": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"exactOptionalPropertyTypes": false,
"forceConsistentCasingInFileNames": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2024"],
"module": "Preserve",
"moduleResolution": "bundler",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": false,
"noImplicitReturns": false,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noEmit": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "es2022",
"types": ["bun", "node"],
"useUnknownInCatchVariables": true
},
"exclude": ["node_modules"],
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View File

@@ -20,6 +20,7 @@
"prepublishOnly": "bun run build"
},
"dependencies": {
"@autumn/logging": "workspace:*",
"@autumn/shared": "workspace:*",
"@axiomhq/js": "^1.6.1",
"@mastra/core": "^1.36.0",

View File

@@ -28,8 +28,10 @@ const defaultEndTime = "now";
const maxRangeMs = ms.days(7);
const searchMaxRangeMs = ms.hours(1);
type AutumnOrg = { id: string; slug?: string | undefined };
let axiomClient: Axiom | null = null;
const orgCache = new Map<string, { orgId: string; expiresAt: Date }>();
const orgCache = new Map<string, { org: AutumnOrg; expiresAt: Date }>();
const getAxiomClient = () => {
if (!process.env.AXIOM_ADMIN_TOKEN) {
@@ -85,9 +87,15 @@ const assertCanUseAxiom = (auth: AutumnMcpAuth) => {
}
};
export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
if (auth.orgId) return auth.orgId;
/**
* Resolves the Autumn org (id + slug) for an authenticated request. Cached
* (~5min) per credential. Unlike `resolveAutumnOrgId`, this always hits
* `/v1/organization` when uncached so the slug is available — the id alone may
* already be on `auth`, but the slug never is.
*/
export const resolveAutumnOrg = async (
auth: AutumnMcpAuth,
): Promise<AutumnOrg> => {
const cacheKey = [
auth.serverURL ?? "https://api.useautumn.com",
auth.env,
@@ -96,7 +104,7 @@ export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
String(auth.failOpen),
].join(":");
const cached = orgCache.get(cacheKey);
if (cached && isFuture(cached.expiresAt)) return cached.orgId;
if (cached && isFuture(cached.expiresAt)) return cached.org;
const client = createAutumnClient(auth);
const response = await fetch(new URL("/v1/organization", client.baseUrl), {
@@ -107,17 +115,26 @@ export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
throw new Error("Could not resolve Autumn organization for MCP request.");
}
const body = (await response.json()) as { id?: unknown };
const body = (await response.json()) as { id?: unknown; slug?: unknown };
if (typeof body.id !== "string" || !body.id) {
throw new Error("Autumn organization response did not include an id.");
}
const org: AutumnOrg = {
id: body.id,
slug: typeof body.slug === "string" ? body.slug : undefined,
};
orgCache.set(cacheKey, {
orgId: body.id,
org,
expiresAt: addMilliseconds(new Date(), ms.minutes(5)),
});
return body.id;
return org;
};
export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
if (auth.orgId) return auth.orgId;
return (await resolveAutumnOrg(auth)).id;
};
export const prepareAxiomQuery = ({

View File

@@ -1,5 +1,5 @@
import type { AnalyticsSink } from "./analyticsTypes.js";
import { createAxiomAnalyticsSink } from "./axiomSink.js";
import { createLoggerAnalyticsSink } from "./loggerSink.js";
const DEFAULT_DATASET = "leaf";
@@ -23,7 +23,7 @@ export const setAnalyticsSink = (sink: AnalyticsSink | null | undefined) => {
export const getAnalyticsSink = (): AnalyticsSink => {
if (overrideSink !== undefined) return overrideSink ?? noopSink;
if (cachedSink === undefined) {
cachedSink = createAxiomAnalyticsSink({
cachedSink = createLoggerAnalyticsSink({
token: process.env.AXIOM_TOKEN,
orgId: process.env.AXIOM_ORG_ID,
dataset: process.env.MCP_ANALYTICS_DATASET ?? DEFAULT_DATASET,

View File

@@ -7,21 +7,33 @@
*/
export type McpAnalyticsSurface = "mcp" | "agent";
/**
* Org/auth context for a tool call. Mirrors the server's `context.*` log shape
* (see server/src/utils/logging) so MCP analytics and agent logs unify cleanly.
*/
export type McpAnalyticsContext = {
/** Autumn org id. Resolved lazily; may be absent if resolution fails. */
orgId?: string | undefined;
/** Autumn org slug. Resolved lazily; may be absent if resolution fails. */
orgSlug?: string | undefined;
env: string;
scopes?: string[] | undefined;
};
export type McpAnalyticsEvent = {
event: "mcp.tool_call";
surface: McpAnalyticsSurface;
tool: string;
/** One-sentence statement of what the caller is trying to do. */
intent?: string | undefined;
status: "ok" | "error";
durationMs: number;
principalId: string;
env: string;
/** Resolved lazily; may be absent if org resolution fails. */
orgId?: string | undefined;
/** HTTP User-Agent of the calling MCP client. Absent for `agent` surface. */
client?: string | undefined;
/** Stateless session grouping: hash(principal + client + time window). */
/** MCP transport session id, or fallback hash(principal + client + window). */
sessionId: string;
scopes?: string[] | undefined;
context: McpAnalyticsContext;
/** Tool request payload (stored as an Axiom map field). */
input?: unknown;
/** Tool result payload (stored as an Axiom map field). */
@@ -32,8 +44,8 @@ export type McpAnalyticsEvent = {
/**
* Pluggable destination for analytics events. Implementations must be
* non-blocking: `emit` runs on the hot path of every tool call and must never
* throw or await network I/O inline. Swap this (Axiom direct, `@axiomhq/pino`,
* an OTEL exporter, a test spy) without touching the instrumentation layer.
* throw or await network I/O inline. Swap this (pino/Axiom, an OTEL exporter,
* a test spy) without touching the instrumentation layer.
*/
export interface AnalyticsSink {
emit(event: McpAnalyticsEvent): void;

View File

@@ -1,72 +0,0 @@
import { Axiom } from "@axiomhq/js";
import type { AnalyticsSink, McpAnalyticsEvent } from "./analyticsTypes.js";
const maxPayloadBytes =
Number(process.env.MCP_ANALYTICS_MAX_PAYLOAD_BYTES) || 512_000;
/**
* Map fields require an object value. Wrap scalars/arrays so heterogeneous
* tool outputs still land in a single Axiom map field instead of conflicting
* on type.
*/
const asMap = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: { value };
/**
* Keep individual events under Axiom's 1MB field cap. Oversized payloads are
* replaced with a marker rather than dropping the whole (otherwise rejected)
* event.
*/
const guardPayload = (value: unknown): unknown => {
if (value === undefined) return undefined;
try {
const json = JSON.stringify(value);
if (json && json.length > maxPayloadBytes) {
return { _truncated: true, _bytes: json.length };
}
return value;
} catch {
return { _unserializable: true };
}
};
const toAxiomRecord = (event: McpAnalyticsEvent) => ({
_time: new Date().toISOString(),
event: event.event,
surface: event.surface,
tool: event.tool,
status: event.status,
duration_ms: event.durationMs,
org_id: event.orgId,
principal_id: event.principalId,
env: event.env,
client: event.client,
session_id: event.sessionId,
scopes: event.scopes,
// Map fields — see scripts/axiom/createLeafDataset.ts
input: asMap(guardPayload(event.input)),
output: asMap(guardPayload(event.output)),
error: event.error,
});
export const createAxiomAnalyticsSink = ({
token,
orgId,
dataset,
}: {
token?: string | undefined;
orgId?: string | undefined;
dataset: string;
}): AnalyticsSink | null => {
if (!token) return null;
const client = new Axiom({ token, orgId });
return {
emit(event) {
// Axiom batches internally; no inline await on the hot path.
client.ingest(dataset, [toAxiomRecord(event)]);
},
flush: () => client.flush(),
};
};

View File

@@ -1,4 +1,4 @@
import { resolveAutumnOrgId } from "../agent/axiom.js";
import { resolveAutumnOrg } from "../agent/axiom.js";
import type { AutumnMcpAuth } from "../server/auth/auth.js";
import { getAnalyticsSink } from "./analyticsSink.js";
import type { McpAnalyticsSurface } from "./analyticsTypes.js";
@@ -14,6 +14,8 @@ export const emitMcpToolEvent = ({
toolId,
auth,
client,
transportSessionId,
intent,
status,
durationMs,
input,
@@ -24,6 +26,8 @@ export const emitMcpToolEvent = ({
toolId: string;
auth: AutumnMcpAuth;
client: string | undefined;
transportSessionId?: string | undefined;
intent?: string | undefined;
status: "ok" | "error";
durationMs: number;
input?: unknown;
@@ -32,33 +36,40 @@ export const emitMcpToolEvent = ({
}) => {
const sink = getAnalyticsSink();
// Resolve org off the hot path; resolveAutumnOrgId is cached (~5min).
// Resolve org off the hot path; resolveAutumnOrg is cached (~5min).
void (async () => {
let orgId = auth.orgId;
if (!orgId) {
try {
orgId = await resolveAutumnOrgId(auth);
} catch {
// Best-effort: emit without org_id rather than dropping the event.
}
let orgSlug: string | undefined;
try {
const org = await resolveAutumnOrg(auth);
orgId = org.id;
orgSlug = org.slug;
} catch {
// Best-effort: emit without org context rather than dropping the event.
}
const now = Date.now();
sink.emit({
event: "mcp.tool_call",
surface,
tool: toolId,
intent,
status,
durationMs,
orgId,
principalId: auth.principalId,
env: auth.env,
client,
sessionId: deriveSessionId({
principalId: auth.principalId,
client,
now,
}),
scopes: auth.scopes,
sessionId:
transportSessionId ??
deriveSessionId({
principalId: auth.principalId,
client,
now,
}),
context: {
orgId,
orgSlug,
env: auth.env,
scopes: auth.scopes,
},
input,
output,
error,

View File

@@ -8,5 +8,8 @@ export type {
McpAnalyticsEvent,
McpAnalyticsSurface,
} from "./analyticsTypes.js";
export { createAxiomAnalyticsSink } from "./axiomSink.js";
export { instrumentToolsWithAnalytics } from "./instrumentTools.js";
export {
createAxiomAnalyticsSink,
createLoggerAnalyticsSink,
} from "./loggerSink.js";

View File

@@ -1,5 +1,6 @@
import type { createTool } from "@mastra/core/tools";
import { type AutumnMcpAuth, getAutumnAuth } from "../server/auth/auth.js";
import { getIntent } from "../tools/utils/intent.js";
import { isAnalyticsEnabled } from "./analyticsSink.js";
import type { McpAnalyticsSurface } from "./analyticsTypes.js";
import { emitMcpToolEvent } from "./emitToolEvent.js";
@@ -7,7 +8,9 @@ import { emitMcpToolEvent } from "./emitToolEvent.js";
type AnyTool = ReturnType<typeof createTool>;
type ToolContext = Parameters<NonNullable<AnyTool["execute"]>>[1];
const getClientFromContext = (context: ToolContext): string | undefined => {
const getHeadersFromContext = (
context: ToolContext,
): Record<string, string | undefined> | undefined => {
const extra = (
context as {
mcp?: {
@@ -17,7 +20,19 @@ const getClientFromContext = (context: ToolContext): string | undefined => {
};
}
)?.mcp?.extra;
return extra?.requestInfo?.headers?.["user-agent"];
return extra?.requestInfo?.headers;
};
const getHeader = (
headers: Record<string, string | undefined> | undefined,
name: string,
): string | undefined => {
const direct = headers?.[name] ?? headers?.[name.toLowerCase()];
if (direct) return direct;
const entry = Object.entries(headers ?? {}).find(
([key]) => key.toLowerCase() === name.toLowerCase(),
);
return entry?.[1];
};
const extractRequest = (input: unknown): unknown =>
@@ -30,8 +45,9 @@ const extractRequest = (input: unknown): unknown =>
* read from the same MCP context the tools already use, so an unauthenticated
* call simply skips analytics (it would have failed in the tool anyway).
*
* Tools are created fresh per request (see `createAutumnOperationsMCPServer`),
* so mutating `execute` here carries no shared-state risk.
* Tools are wrapped once when the MCP server is created. The wrapper keeps no
* per-request mutable state; auth/session data is read from the execution
* context for each tool call.
*
* @param tools The toolset to instrument (mutated in place and returned).
* @param surface Origin of the calls — `mcp` (external clients) or `agent`
@@ -59,7 +75,10 @@ export const instrumentToolsWithAnalytics = <
} catch {
return original(input as never, context as never);
}
const client = getClientFromContext(context);
const headers = getHeadersFromContext(context);
const client = getHeader(headers, "user-agent");
const transportSessionId = getHeader(headers, "mcp-session-id");
const intent = getIntent(input);
try {
const output = await original(input as never, context as never);
emitMcpToolEvent({
@@ -67,6 +86,8 @@ export const instrumentToolsWithAnalytics = <
toolId,
auth,
client,
transportSessionId,
intent,
status: "ok",
durationMs: Date.now() - started,
input: extractRequest(input),
@@ -79,6 +100,8 @@ export const instrumentToolsWithAnalytics = <
toolId,
auth,
client,
transportSessionId,
intent,
status: "error",
durationMs: Date.now() - started,
input: extractRequest(input),

View File

@@ -0,0 +1,60 @@
import { asAxiomMap, createLogger, guardLogPayload } from "@autumn/logging";
import type { AnalyticsSink, McpAnalyticsEvent } from "./analyticsTypes.js";
const toLoggerRecord = (event: McpAnalyticsEvent) => ({
_time: new Date().toISOString(),
event: event.event,
surface: event.surface,
tool: event.tool,
intent: event.intent,
status: event.status,
duration_ms: event.durationMs,
principal_id: event.principalId,
client: event.client,
session_id: event.sessionId,
context: {
org_id: event.context.orgId,
org_slug: event.context.orgSlug,
env: event.context.env,
scopes: event.context.scopes,
},
input: asAxiomMap({ value: guardLogPayload({ value: event.input }) }),
output: asAxiomMap({ value: guardLogPayload({ value: event.output }) }),
error: event.error,
});
export const createLoggerAnalyticsSink = ({
token,
orgId,
dataset,
}: {
token?: string | undefined;
orgId?: string | undefined;
dataset: string;
}): AnalyticsSink | null => {
if (!token) return null;
const logger = createLogger({
service: "mcp",
dataset,
preset: "axiom-only",
outputs: ["axiom"],
axiomToken: token,
axiomOrgId: orgId,
});
return {
emit(event) {
logger.info(toLoggerRecord(event), "MCP tool call");
},
flush: async () => {
await new Promise<void>((resolve) => {
const flush = logger.flush;
if (typeof flush !== "function") return resolve();
flush.call(logger, () => resolve());
});
},
};
};
/** @deprecated Use createLoggerAnalyticsSink. */
export const createAxiomAnalyticsSink = createLoggerAnalyticsSink;

View File

@@ -7,10 +7,9 @@ const hash = (value: string) =>
createHash("sha256").update(value).digest("hex").slice(0, 32);
/**
* Stateless session grouping. The serverless MCP transport issues no
* Mcp-Session-Id, so we synthesize one from the principal + client + a coarse
* time bucket — calls from the same client within the window collapse into one
* session.
* Fallback session grouping. Stateful MCP clients send Mcp-Session-Id; when it
* is absent, synthesize a coarse principal/client bucket so calls from the same
* client within the window still collapse into one session.
*/
export const deriveSessionId = ({
principalId,

View File

@@ -1,6 +1,5 @@
import { ms } from "@autumn/shared/unixUtils";
import { addMilliseconds, isFuture } from "date-fns";
import type { ConsoleLogger } from "../../console-logger.js";
import { MCP_OAUTH_SCOPES } from "../../constants.js";
import type { AutumnMcpAuth } from "./auth.js";
import { OAuthHttpError } from "./utils/errors.js";
@@ -37,6 +36,10 @@ type ExchangedToken = {
scopes?: string[] | undefined;
};
type AuthLogger = {
warning: (message: string, data?: Record<string, unknown>) => void;
};
const apiKeyCache = new Map<string, ExchangedToken & { expiresAt: Date }>();
const exchangeOAuthToken = async ({
@@ -132,7 +135,7 @@ export const getAuthorizationServerMetadata = (flags: MCPOAuthFlags) => {
export const buildAuthForRequest = async (
headers: Headers,
flags: MCPOAuthFlags,
logger: ConsoleLogger,
logger: AuthLogger,
resourcePath = "/mcp",
): Promise<AutumnMcpAuth> => {
const env = getEnvironment({ headers, flags });

View File

@@ -18,6 +18,7 @@ import {
rawLocalPreviewTool,
toTools,
} from "./utils/factories.js";
import { requireIntentOnTools } from "./utils/intent.js";
import type { ConfirmedWriteToolName, ToolDomain } from "./utils/types.js";
export { dateToEpochMillisecondsTool } from "./utils/dates.js";
@@ -62,14 +63,16 @@ const confirmedWrites = domains.flatMap(
*/
export const createRawAutumnOperationTools = () =>
instrumentToolsWithAnalytics({
tools: {
// Require a one-sentence `intent` on every external tool call so we can
// see what clients are actually trying to do (captured in analytics).
tools: requireIntentOnTools({
...toTools(operations, operationTool),
...toTools(billingPreviews, (config) =>
operationTool({ ...config, endpoint: config.previewEndpoint }),
),
...toTools(localPreviews, rawLocalPreviewTool),
...toTools(confirmedWrites, operationTool),
},
}),
surface: "mcp",
});

View File

@@ -0,0 +1,47 @@
import type { createTool } from "@mastra/core/tools";
import * as z from "zod/v4";
type AnyTool = ReturnType<typeof createTool>;
export const INTENT_DESCRIPTION =
"Required. One concise sentence, in plain language, describing what the user " +
"asked you (the agent) to do — their original request in their own terms, " +
"not a restatement of the arguments or the tool name. If this call is one " +
'step toward a larger ask, state that larger ask. Example: "Find customers ' +
'on the Pro plan so we can email them about the new add-on."';
/** Required single-sentence statement of what the caller is trying to do. */
export const intentSchema = z.string().min(1).describe(INTENT_DESCRIPTION);
/** Reads the `intent` string out of a tool input without casting. */
export const getIntent = (input: unknown): string | undefined =>
input &&
typeof input === "object" &&
"intent" in input &&
typeof input.intent === "string"
? input.intent
: undefined;
/**
* Adds a required `intent` field to every tool's input schema, in place, so
* external MCP clients must declare their goal on every call. Call this once on
* a fully-built toolset (the intent is captured by the analytics layer).
*
* Tools whose input isn't a plain object are left untouched.
*/
export const requireIntentOnTools = <T extends Record<string, AnyTool>>(
tools: T,
): T => {
for (const tool of Object.values(tools)) {
const schema = tool.inputSchema;
if (schema instanceof z.ZodObject) {
// Runtime value is a plain zod object, but Mastra types the field as its
// JSON-schema-augmented schema (incompatible at the type level only), so
// route the reassignment through `unknown`.
tool.inputSchema = schema.extend({
intent: intentSchema,
}) as unknown as typeof tool.inputSchema;
}
}
return tools;
};

View File

@@ -104,7 +104,10 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{ request: { customer_id: "cus_1", email: "charlie@example.com" } },
{
intent: "create a customer",
request: { customer_id: "cus_1", email: "charlie@example.com" },
},
{ mcp: { extra: { authInfo: auth } } } as never,
),
).resolves.toEqual({ id: "cus_1" });
@@ -129,9 +132,12 @@ describe("Autumn operation tools", () => {
if (!tool.execute) throw new Error("createPlan is not executable");
await expect(
tool.execute({ request: { plan_id: "pro", name: "Pro" } }, {
mcp: { extra: { authInfo: auth } },
} as never),
tool.execute(
{ intent: "create a plan", request: { plan_id: "pro", name: "Pro" } },
{
mcp: { extra: { authInfo: auth } },
} as never,
),
).resolves.toEqual({ id: "pro" });
} finally {
globalThis.fetch = originalFetch;
@@ -157,6 +163,7 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{
intent: "create a schedule",
request: {
customer_id: "cus_1",
phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }],
@@ -189,7 +196,7 @@ describe("Autumn operation tools", () => {
throw new Error("previewCreateBalance is not executable");
await expect(
tool.execute({ request }, {
tool.execute({ intent: "preview a balance grant", request }, {
mcp: { extra: { authInfo: auth } },
} as never),
).resolves.toMatchObject({
@@ -222,6 +229,7 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{
intent: "grant a balance",
request: {
customer_id: "cus_1",
entity_id: "workspace_1",
@@ -259,6 +267,7 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{
intent: "preview a schedule",
request: {
customer_id: "cus_1",
phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }],
@@ -288,9 +297,15 @@ describe("Autumn operation tools", () => {
if (!tool.execute) throw new Error("listCustomers is not executable");
await expect(
tool.execute({ request: { limit: 5000, search: "charlie" } }, {
mcp: { extra: { authInfo: auth } },
} as never),
tool.execute(
{
intent: "list customers",
request: { limit: 5000, search: "charlie" },
},
{
mcp: { extra: { authInfo: auth } },
} as never,
),
).resolves.toEqual({ customers: [] });
} finally {
globalThis.fetch = originalFetch;
@@ -317,9 +332,15 @@ describe("Autumn operation tools", () => {
if (!tool.execute) throw new Error("previewAttach is not executable");
await expect(
tool.execute({ request: { customer_id: "cus_1", plan_id: "pro" } }, {
mcp: { extra: { authInfo: auth } },
} as never),
tool.execute(
{
intent: "preview an attach",
request: { customer_id: "cus_1", plan_id: "pro" },
},
{
mcp: { extra: { authInfo: auth } },
} as never,
),
).resolves.toEqual({ total: 50 });
await expect(claimLatestPendingAction(auth)).rejects.toThrow(
"No pending",
@@ -346,9 +367,15 @@ describe("Autumn operation tools", () => {
if (!tool.execute) throw new Error("attach is not executable");
await expect(
tool.execute({ request: { customer_id: "cus_1", plan_id: "pro" } }, {
mcp: { extra: { authInfo: auth } },
} as never),
tool.execute(
{
intent: "attach a plan",
request: { customer_id: "cus_1", plan_id: "pro" },
},
{
mcp: { extra: { authInfo: auth } },
} as never,
),
).resolves.toEqual({ ok: true });
} finally {
globalThis.fetch = originalFetch;

View File

@@ -0,0 +1,130 @@
import { describe, expect, test } from "bun:test";
import { createTool } from "@mastra/core/tools";
import * as z from "zod/v4";
import {
instrumentToolsWithAnalytics,
type McpAnalyticsEvent,
setAnalyticsSink,
} from "../../../src/analytics/index.js";
import type { AutumnMcpAuth } from "../../../src/server/auth/auth.js";
const auth: AutumnMcpAuth = {
apiKey: "sk_test",
env: "sandbox",
principalId: "user_1",
resource: "http://localhost:2718/mcp",
scopes: ["billing:read"],
serverURL: "http://localhost:8080",
};
const waitForEvent = async (events: McpAnalyticsEvent[]) => {
for (let i = 0; i < 20; i++) {
if (events.length > 0) return events[0];
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error("Timed out waiting for analytics event");
};
describe("MCP analytics instrumentation", () => {
test("emits successful tool calls", async () => {
const events: McpAnalyticsEvent[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
Response.json({ id: "org_1", slug: "acme" })) as unknown as typeof fetch;
setAnalyticsSink({
emit: (event: McpAnalyticsEvent) => events.push(event),
flush: async () => {},
});
try {
const tools = instrumentToolsWithAnalytics({
surface: "mcp",
tools: {
echo: createTool({
id: "echo",
description: "Echo input",
inputSchema: z.object({ intent: z.string(), request: z.unknown() }),
execute: async ({ request }) => ({ request }),
}),
},
});
await expect(
tools.echo.execute?.({ intent: "echo input", request: { ok: true } }, {
mcp: {
extra: {
authInfo: auth,
requestInfo: {
headers: {
"mcp-session-id": "mcp_session_1",
"user-agent": "Claude Code",
},
},
},
},
} as never),
).resolves.toEqual({ request: { ok: true } });
await expect(waitForEvent(events)).resolves.toMatchObject({
event: "mcp.tool_call",
surface: "mcp",
tool: "echo",
intent: "echo input",
status: "ok",
principalId: "user_1",
client: "Claude Code",
sessionId: "mcp_session_1",
context: {
orgId: "org_1",
orgSlug: "acme",
env: "sandbox",
},
input: { ok: true },
output: { request: { ok: true } },
});
} finally {
setAnalyticsSink(undefined);
globalThis.fetch = originalFetch;
}
});
test("emits errors and rethrows", async () => {
const events: McpAnalyticsEvent[] = [];
setAnalyticsSink({
emit: (event: McpAnalyticsEvent) => events.push(event),
flush: async () => {},
});
try {
const tools = instrumentToolsWithAnalytics({
surface: "agent",
tools: {
fail: createTool({
id: "fail",
description: "Fail input",
inputSchema: z.object({ intent: z.string() }),
execute: async () => {
throw new Error("nope");
},
}),
},
});
await expect(
tools.fail.execute?.({ intent: "fail intentionally" }, {
mcp: { extra: { authInfo: auth } },
} as never),
).rejects.toThrow("nope");
await expect(waitForEvent(events)).resolves.toMatchObject({
surface: "agent",
tool: "fail",
intent: "fail intentionally",
status: "error",
error: "nope",
});
} finally {
setAnalyticsSink(undefined);
}
});
});

View File

@@ -33,6 +33,7 @@
"@api/*": ["../../shared/api/*"],
"@models/*": ["../../shared/models/*"],
"@utils/*": ["../../shared/utils/*"],
"@autumn/logging": ["../logging/src/index.ts"],
"@autumn/ksuid": ["../ksuid/src/index.ts"]
},
"useUnknownInCatchVariables": true,

View File

@@ -1,14 +1,12 @@
/**
* Idempotently provisions the Axiom `leaf` dataset used for MCP usage
* analytics (events emitted from packages/mcp `tool.execute`), and configures
* its map fields.
* Idempotently provisions the Axiom `leaf` dataset used for Leaf runtime logs
* and MCP usage analytics, and configures its map fields.
*
* Map fields ("vacuum" the unpredictable nested payloads into a single column):
* MCP tool `input`/`output` payloads have an open-ended shape — every distinct
* arg key would otherwise become its own mapped field and quickly blow Axiom's
* per-dataset field limit. Declaring `input` and `output` as map fields stores
* their nested keys inside one field each, so they never count toward the limit
* while staying queryable (e.g. `where input.customer_id == '...'`).
* Tool payloads, req/res bodies, and per-log details have open-ended shape.
* Every distinct top-level key would otherwise become its own mapped field and
* quickly blow Axiom's per-dataset field limit. These map fields keep nested
* keys inside one field each while staying queryable.
*
* Run via the Axiom CLI (resolves AXIOM_ADMIN_TOKEN from infisical):
* bun axiom create-leaf # dev
@@ -17,17 +15,25 @@
* Notes:
* - AXIOM_ADMIN_TOKEN must be a personal API token with dataset create/update
* scope, NOT the `xaat-` ingest token used at runtime.
* - Safe to re-run: dataset creation tolerates "already exists", and the map
* field list is declared via PUT (full replace), so re-running converges.
* - Safe to re-run: dataset creation tolerates "already exists", and existing
* map fields are read before missing fields are created.
*/
const AXIOM_BASE = "https://api.axiom.co/v2";
const DATASET = "leaf";
const DATASET_DESCRIPTION = "Leaf app MCP usage analytics (per tool.execute)";
const DATASET_DESCRIPTION = "Leaf runtime logs and MCP usage analytics";
// Nested, open-ended payloads stored as map fields to stay under the field
// limit. Keep this list minimal — only genuinely high-cardinality objects.
const MAP_FIELDS = ["input", "output"];
const MAP_FIELDS = [
"context",
"data",
"extras",
"input",
"output",
"req",
"res",
];
const authHeaders = (token: string) => ({
Authorization: `Bearer ${token}`,
@@ -59,7 +65,42 @@ const createDataset = async (token: string) => {
throw new Error(`Failed to create dataset: ${res.status} ${text}`);
};
const setMapField = async (token: string, name: string) => {
const getMapFields = async (token: string) => {
const res = await fetch(
`${AXIOM_BASE}/datasets/${encodeURIComponent(DATASET)}/mapfields`,
{
method: "GET",
headers: authHeaders(token),
},
);
const text = await res.text();
if (!res.ok) {
throw new Error(`Failed to list map fields: ${res.status} ${text}`);
}
const parsed = JSON.parse(text) as unknown;
if (!Array.isArray(parsed) || parsed.some((name) => typeof name !== "string")) {
throw new Error(`Unexpected map fields response: ${text}`);
}
return new Set(parsed);
};
const setMapField = async ({
existing,
name,
token,
}: {
existing: Set<string>;
name: string;
token: string;
}) => {
if (existing.has(name)) {
console.log(` = map field: ${name} (already set)`);
return;
}
const res = await fetch(
`${AXIOM_BASE}/datasets/${encodeURIComponent(DATASET)}/mapfields`,
{
@@ -69,13 +110,15 @@ const setMapField = async (token: string, name: string) => {
},
);
// Re-declaring an existing map field returns a 4xx mentioning existence.
const text = await res.text();
if (res.ok) {
existing.add(name);
console.log(` + map field: ${name}`);
return;
}
if (/exist/i.test(text)) {
existing.add(name);
console.log(` = map field: ${name} (already set)`);
return;
}
@@ -84,8 +127,9 @@ const setMapField = async (token: string, name: string) => {
};
const setMapFields = async (token: string) => {
const existing = await getMapFields(token);
for (const name of MAP_FIELDS) {
await setMapField(token, name);
await setMapField({ existing, name, token });
}
};

91
scripts/mcp/addMcp.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Registers the Autumn MCP server with local AI CLIs (Claude Code + Codex).
*
* Usage:
* bun add-mcp # autumn-dev -> http://localhost:3099/mcp
* bun add-mcp <name> <url> # custom name / url
*
* Only CLIs that are actually installed are touched; the rest are skipped.
* The server uses OAuth, so you authenticate on first connect (Claude prompts
* automatically; for Codex run `codex mcp login <name>`).
*/
const DEFAULT_NAME = "autumn-dev";
const DEFAULT_URL = "http://localhost:3099/mcp";
type Client = {
label: string;
bin: string;
/** Args to remove an existing server of this name (best-effort, ignored). */
removeArgs: (name: string) => string[];
/** Args to add the streamable-HTTP server. */
addArgs: (name: string, url: string) => string[];
/** Follow-up the user must run/do (e.g. OAuth login). */
next: (name: string) => string;
};
const clients: Client[] = [
{
label: "Claude Code",
bin: "claude",
removeArgs: (name) => ["mcp", "remove", name],
addArgs: (name, url) => ["mcp", "add", "--transport", "http", name, url],
next: () => "Claude prompts for OAuth automatically on first use.",
},
{
label: "Codex",
bin: "codex",
removeArgs: (name) => ["mcp", "remove", name],
addArgs: (name, url) => ["mcp", "add", name, "--url", url],
next: (name) =>
`Run \`codex mcp login ${name}\` to authenticate (OAuth). If the handshake fails, retry with \`-c experimental_use_rmcp_client=true\`.`,
},
];
const run = (bin: string, args: string[]) => {
const proc = Bun.spawnSync([bin, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const output = `${proc.stdout.toString()}${proc.stderr.toString()}`.trim();
return { ok: proc.exitCode === 0, output };
};
const addToClient = (client: Client, name: string, url: string) => {
if (!Bun.which(client.bin)) {
console.log(`- ${client.label}: skipped (\`${client.bin}\` not found)`);
return;
}
// Remove any existing entry first so re-running converges cleanly.
run(client.bin, client.removeArgs(name));
const { ok, output } = run(client.bin, client.addArgs(name, url));
if (ok) {
console.log(`+ ${client.label}: added \`${name}\` -> ${url}`);
console.log(` next: ${client.next(name)}`);
return;
}
console.log(`! ${client.label}: failed to add \`${name}\``);
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
};
const main = () => {
const [, , nameArg, urlArg] = process.argv;
if (nameArg === "--help" || nameArg === "-h") {
console.log("Usage: bun add-mcp [name] [url]");
console.log(`Defaults: ${DEFAULT_NAME} ${DEFAULT_URL}`);
return;
}
const name = nameArg ?? DEFAULT_NAME;
const url = urlArg ?? DEFAULT_URL;
console.log(`Registering MCP server \`${name}\` (${url})\n`);
for (const client of clients) {
addToClient(client, name, url);
}
};
main();