analytics
This commit is contained in:
2
ai
2
ai
Submodule ai updated: a84bc7418c...0e52f71fbd
@@ -10,6 +10,7 @@
|
||||
"ts": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@autumn/logging": "workspace:*",
|
||||
"@autumn/mcp": "workspace:*",
|
||||
"@autumn/shared": "workspace:*",
|
||||
"@chat-adapter/slack": "^4.29.0",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
60
apps/leaf/src/lib/logger.ts
Normal file
60
apps/leaf/src/lib/logger.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
32
apps/leaf/tests/unit/lib/logger.test.ts
Normal file
32
apps/leaf/tests/unit/lib/logger.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/*"],
|
||||
|
||||
23
bun.lock
23
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
|
||||
30
packages/logging/package.json
Normal file
30
packages/logging/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
packages/logging/src/context/addContextToLogs.ts
Normal file
38
packages/logging/src/context/addContextToLogs.ts
Normal 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 } });
|
||||
35
packages/logging/src/context/types.ts
Normal file
35
packages/logging/src/context/types.ts
Normal 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;
|
||||
};
|
||||
21
packages/logging/src/ids/createSessionId.ts
Normal file
21
packages/logging/src/ids/createSessionId.ts
Normal 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);
|
||||
3
packages/logging/src/ids/createTraceId.ts
Normal file
3
packages/logging/src/ids/createTraceId.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export const createTraceId = (): string => randomUUID();
|
||||
40
packages/logging/src/index.ts
Normal file
40
packages/logging/src/index.ts
Normal 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";
|
||||
69
packages/logging/src/logger/autumnLogger.ts
Normal file
69
packages/logging/src/logger/autumnLogger.ts
Normal 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) });
|
||||
28
packages/logging/src/logger/consoleLogger.ts
Normal file
28
packages/logging/src/logger/consoleLogger.ts
Normal 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;
|
||||
};
|
||||
60
packages/logging/src/logger/createLogger.ts
Normal file
60
packages/logging/src/logger/createLogger.ts
Normal 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),
|
||||
);
|
||||
};
|
||||
71
packages/logging/src/logger/loggerWrappers.ts
Normal file
71
packages/logging/src/logger/loggerWrappers.ts
Normal 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 }),
|
||||
};
|
||||
};
|
||||
67
packages/logging/src/logger/resolveLoggerOptions.ts
Normal file
67
packages/logging/src/logger/resolveLoggerOptions.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
8
packages/logging/src/payload/asAxiomMap.ts
Normal file
8
packages/logging/src/payload/asAxiomMap.ts
Normal 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 };
|
||||
147
packages/logging/src/payload/guardLogPayload.ts
Normal file
147
packages/logging/src/payload/guardLogPayload.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
9
packages/logging/src/streams/consoleJsonStream.ts
Normal file
9
packages/logging/src/streams/consoleJsonStream.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
116
packages/logging/src/streams/prettyLogStream.ts
Normal file
116
packages/logging/src/streams/prettyLogStream.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
});
|
||||
56
packages/logging/src/types.ts
Normal file
56
packages/logging/src/types.ts
Normal 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;
|
||||
34
packages/logging/tsconfig.json
Normal file
34
packages/logging/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
60
packages/mcp/src/analytics/loggerSink.ts
Normal file
60
packages/mcp/src/analytics/loggerSink.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
47
packages/mcp/src/tools/utils/intent.ts
Normal file
47
packages/mcp/src/tools/utils/intent.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
130
packages/mcp/tests/unit/mcp-server/analytics.test.ts
Normal file
130
packages/mcp/tests/unit/mcp-server/analytics.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
91
scripts/mcp/addMcp.ts
Normal 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();
|
||||
Reference in New Issue
Block a user