analytics
This commit is contained in:
2
ai
2
ai
Submodule ai updated: a84bc7418c...0e52f71fbd
@@ -10,6 +10,7 @@
|
|||||||
"ts": "tsc --noEmit"
|
"ts": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@autumn/logging": "workspace:*",
|
||||||
"@autumn/mcp": "workspace:*",
|
"@autumn/mcp": "workspace:*",
|
||||||
"@autumn/shared": "workspace:*",
|
"@autumn/shared": "workspace:*",
|
||||||
"@chat-adapter/slack": "^4.29.0",
|
"@chat-adapter/slack": "^4.29.0",
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
|
import type { AutumnLogger } from "@autumn/logging";
|
||||||
import { AppEnv } from "@autumn/shared";
|
import { AppEnv } from "@autumn/shared";
|
||||||
import { Agent } from "@mastra/core/agent";
|
import { Agent } from "@mastra/core/agent";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
|
||||||
createAutumnMcpClient,
|
|
||||||
getAutumnMcpTools,
|
|
||||||
} from "./mcp.js";
|
|
||||||
import { createFirecrawlTools } from "./firecrawl.js";
|
|
||||||
import { env as chatEnv } from "../lib/env.js";
|
import { env as chatEnv } from "../lib/env.js";
|
||||||
|
import { logger as rootLogger } from "../lib/logger.js";
|
||||||
import type { ChatContextMessage } from "../types.js";
|
import type { ChatContextMessage } from "../types.js";
|
||||||
|
import { createFirecrawlTools } from "./firecrawl.js";
|
||||||
|
import { createAutumnMcpClient, getAutumnMcpTools } from "./mcp.js";
|
||||||
|
|
||||||
const docs = [
|
const docs = [
|
||||||
"autumn://docs/tool-composition",
|
"autumn://docs/tool-composition",
|
||||||
@@ -37,15 +36,25 @@ const recentMessageContext = (messages: ChatContextMessage[] = []) =>
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const selectChatEnv = async ({
|
export const selectChatEnv = async ({
|
||||||
|
logger = rootLogger,
|
||||||
message,
|
message,
|
||||||
recentMessages,
|
recentMessages,
|
||||||
select,
|
select,
|
||||||
}: {
|
}: {
|
||||||
|
logger?: AutumnLogger;
|
||||||
message: string;
|
message: string;
|
||||||
recentMessages?: ChatContextMessage[];
|
recentMessages?: ChatContextMessage[];
|
||||||
select?: () => Promise<unknown> | unknown;
|
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({
|
const agent = new Agent({
|
||||||
id: "autumn-chat-env",
|
id: "autumn-chat-env",
|
||||||
@@ -61,9 +70,12 @@ export const selectChatEnv = async ({
|
|||||||
instructions:
|
instructions:
|
||||||
"Return live unless the latest user request clearly asks to use sandbox or test mode.",
|
"Return live unless the latest user request clearly asks to use sandbox or test mode.",
|
||||||
},
|
},
|
||||||
context: [
|
context: [...recentMessageContext(recentMessages)],
|
||||||
...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;
|
return output.object.env;
|
||||||
};
|
};
|
||||||
@@ -86,6 +98,7 @@ const readDocs = async (mcp: ReturnType<typeof createAutumnMcpClient>) => {
|
|||||||
export const runChatAgent = async ({
|
export const runChatAgent = async ({
|
||||||
apiKey,
|
apiKey,
|
||||||
env,
|
env,
|
||||||
|
logger = rootLogger,
|
||||||
message,
|
message,
|
||||||
threadId,
|
threadId,
|
||||||
resourceId,
|
resourceId,
|
||||||
@@ -95,6 +108,7 @@ export const runChatAgent = async ({
|
|||||||
}: {
|
}: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
env: AppEnv;
|
env: AppEnv;
|
||||||
|
logger?: AutumnLogger;
|
||||||
message: string;
|
message: string;
|
||||||
onAction?: (message: string) => Promise<void> | void;
|
onAction?: (message: string) => Promise<void> | void;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
@@ -111,10 +125,22 @@ export const runChatAgent = async ({
|
|||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
try {
|
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");
|
await onAction?.("Loading Autumn tools and guidance");
|
||||||
const [tools, docsText] = await Promise.all([
|
const [tools, docsText] = await Promise.all([
|
||||||
getAutumnMcpTools(mcp, {
|
getAutumnMcpTools(mcp, {
|
||||||
applyApprovalPolicy: true,
|
applyApprovalPolicy: true,
|
||||||
|
logger,
|
||||||
onToolCall: onAction,
|
onToolCall: onAction,
|
||||||
onPreview: (approval) => {
|
onPreview: (approval) => {
|
||||||
previewApproval = approval;
|
previewApproval = approval;
|
||||||
@@ -150,8 +176,19 @@ export const runChatAgent = async ({
|
|||||||
...recentMessageContext(recentMessages),
|
...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 };
|
return { ...output, env, previewApproval };
|
||||||
} finally {
|
} finally {
|
||||||
await mcp.disconnect();
|
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 { MCPClient } from "@mastra/mcp";
|
||||||
import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
|
|
||||||
import { env } from "../lib/env.js";
|
import { env } from "../lib/env.js";
|
||||||
|
import { logger as rootLogger } from "../lib/logger.js";
|
||||||
|
import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
|
||||||
|
|
||||||
type AutumnTool = {
|
type AutumnTool = {
|
||||||
execute?: (
|
execute?: (
|
||||||
@@ -14,6 +16,7 @@ type AutumnTool = {
|
|||||||
|
|
||||||
type ToolOptions = {
|
type ToolOptions = {
|
||||||
applyApprovalPolicy?: boolean;
|
applyApprovalPolicy?: boolean;
|
||||||
|
logger?: AutumnLogger;
|
||||||
onToolCall?: (message: string) => Promise<void> | void;
|
onToolCall?: (message: string) => Promise<void> | void;
|
||||||
onPreview?: (approval: {
|
onPreview?: (approval: {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
@@ -77,12 +80,25 @@ export const getAutumnMcpTools = async (
|
|||||||
mcp: MCPClient,
|
mcp: MCPClient,
|
||||||
options: ToolOptions = {},
|
options: ToolOptions = {},
|
||||||
) => {
|
) => {
|
||||||
|
const logger = options.logger ?? rootLogger;
|
||||||
const { toolsets, errors } = await mcp.listToolsetsWithErrors();
|
const { toolsets, errors } = await mcp.listToolsetsWithErrors();
|
||||||
if (Object.keys(errors).length) {
|
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>;
|
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)) {
|
for (const [toolName, tool] of Object.entries(tools)) {
|
||||||
if (options.applyApprovalPolicy) {
|
if (options.applyApprovalPolicy) {
|
||||||
tool.requireApproval = tool.mcp?.annotations?.destructiveHint === true;
|
tool.requireApproval = tool.mcp?.annotations?.destructiveHint === true;
|
||||||
@@ -91,10 +107,21 @@ export const getAutumnMcpTools = async (
|
|||||||
if (tool.execute && (options.onToolCall || options.onPreview)) {
|
if (tool.execute && (options.onToolCall || options.onPreview)) {
|
||||||
const execute = tool.execute.bind(tool);
|
const execute = tool.execute.bind(tool);
|
||||||
tool.execute = async (args, ...rest) => {
|
tool.execute = async (args, ...rest) => {
|
||||||
|
logger.info("Calling Autumn MCP tool", {
|
||||||
|
event: "leaf.mcp_tool_called",
|
||||||
|
tool: toolName,
|
||||||
|
});
|
||||||
await options.onToolCall?.(formatToolAction(toolName, args));
|
await options.onToolCall?.(formatToolAction(toolName, args));
|
||||||
const result = await execute(args, ...rest);
|
const result = await execute(args, ...rest);
|
||||||
const writeTool = getWriteToolForPreview(toolName);
|
const writeTool = getWriteToolForPreview(toolName);
|
||||||
if (writeTool) {
|
if (writeTool) {
|
||||||
|
logger.info("Captured Autumn MCP preview", {
|
||||||
|
event: "leaf.mcp_preview_captured",
|
||||||
|
tool: writeTool,
|
||||||
|
data: {
|
||||||
|
preview_tool: toolName,
|
||||||
|
},
|
||||||
|
});
|
||||||
options.onPreview?.({
|
options.onPreview?.({
|
||||||
toolName: writeTool,
|
toolName: writeTool,
|
||||||
toolArgs: args,
|
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 { getInstallationKey } from "../providers/slack/installations.js";
|
||||||
import { agentOutputSchema, type BotMessage } from "../types.js";
|
import { agentOutputSchema, type BotMessage } from "../types.js";
|
||||||
|
import { runChatAgent, selectChatEnv } from "./agent.js";
|
||||||
|
|
||||||
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
|
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
|
||||||
new Promise<T>((resolve, reject) => {
|
new Promise<T>((resolve, reject) => {
|
||||||
@@ -13,6 +14,7 @@ const withTimeout = <T>(promise: Promise<T>, ms: number) =>
|
|||||||
|
|
||||||
export const runMessage = async ({
|
export const runMessage = async ({
|
||||||
installation,
|
installation,
|
||||||
|
logger = rootLogger,
|
||||||
onAction,
|
onAction,
|
||||||
recentMessages,
|
recentMessages,
|
||||||
text,
|
text,
|
||||||
@@ -23,11 +25,21 @@ export const runMessage = async ({
|
|||||||
const env = await selectChatEnv({
|
const env = await selectChatEnv({
|
||||||
message: text,
|
message: text,
|
||||||
recentMessages,
|
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(
|
return agentOutputSchema.parse(
|
||||||
await runChatAgent({
|
await runChatAgent({
|
||||||
apiKey: getInstallationKey(installation, env),
|
apiKey: getInstallationKey(installation, env),
|
||||||
env,
|
env,
|
||||||
|
logger,
|
||||||
message: text,
|
message: text,
|
||||||
onAction,
|
onAction,
|
||||||
threadId,
|
threadId,
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
|
import type { AutumnLogger } from "@autumn/logging";
|
||||||
import type { ChatApproval, ChatInstallation } from "@autumn/shared";
|
import type { ChatApproval, ChatInstallation } from "@autumn/shared";
|
||||||
import type { ActionEvent } from "chat";
|
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 {
|
import {
|
||||||
approveAndRun,
|
approveAndRun,
|
||||||
cancelApproval,
|
cancelApproval,
|
||||||
@@ -7,21 +18,13 @@ import {
|
|||||||
getApproval,
|
getApproval,
|
||||||
isErrorResult,
|
isErrorResult,
|
||||||
} from "./store.js";
|
} 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 ({
|
export const postApprovalRequest = async ({
|
||||||
channelId,
|
channelId,
|
||||||
installation,
|
installation,
|
||||||
loading,
|
loading,
|
||||||
logAction,
|
logAction,
|
||||||
|
logger = rootLogger,
|
||||||
output,
|
output,
|
||||||
providerUserId,
|
providerUserId,
|
||||||
target,
|
target,
|
||||||
@@ -30,6 +33,7 @@ export const postApprovalRequest = async ({
|
|||||||
installation: ChatInstallation;
|
installation: ChatInstallation;
|
||||||
loading: LoadingState;
|
loading: LoadingState;
|
||||||
logAction: (message: string) => Promise<void> | void;
|
logAction: (message: string) => Promise<void> | void;
|
||||||
|
logger?: AutumnLogger;
|
||||||
output: AgentOutput;
|
output: AgentOutput;
|
||||||
providerUserId: string;
|
providerUserId: string;
|
||||||
target: ReplyTarget;
|
target: ReplyTarget;
|
||||||
@@ -47,6 +51,15 @@ export const postApprovalRequest = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await logAction(`Waiting for approval: ${toolLabel(approval.toolName)}`);
|
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 finishLoading(target, loading, "Preview ready.");
|
||||||
await target.post(
|
await target.post(
|
||||||
approvalCard({
|
approvalCard({
|
||||||
@@ -92,10 +105,22 @@ export const handleApprovalAction = async (event: ActionEvent) => {
|
|||||||
if (!event.value) return;
|
if (!event.value) return;
|
||||||
|
|
||||||
try {
|
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);
|
const details = await approvalDetails(event.value);
|
||||||
if (event.actionId === "cancel_billing_action") {
|
if (event.actionId === "cancel_billing_action") {
|
||||||
const cancelled = await cancelApproval(event.value, event.user.userId);
|
const cancelled = await cancelApproval(event.value, event.user.userId);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
rootLogger.warn("Approval cancellation ignored", {
|
||||||
|
event: "leaf.approval_cancel_ignored",
|
||||||
|
approval_id: event.value,
|
||||||
|
});
|
||||||
const current = await getApproval(event.value);
|
const current = await getApproval(event.value);
|
||||||
await editActionMessage(
|
await editActionMessage(
|
||||||
event,
|
event,
|
||||||
@@ -110,6 +135,11 @@ export const handleApprovalAction = async (event: ActionEvent) => {
|
|||||||
event,
|
event,
|
||||||
approvalStatusCard({ status: "cancelled", ...details }),
|
approvalStatusCard({ status: "cancelled", ...details }),
|
||||||
);
|
);
|
||||||
|
rootLogger.info("Cancelled approval", {
|
||||||
|
event: "leaf.approval_cancelled",
|
||||||
|
approval_id: event.value,
|
||||||
|
tool: details.toolName,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +148,12 @@ export const handleApprovalAction = async (event: ActionEvent) => {
|
|||||||
approvalStatusCard({ status: "running", ...details }),
|
approvalStatusCard({ status: "running", ...details }),
|
||||||
);
|
);
|
||||||
const result = await approveAndRun(event.value, event.user.userId);
|
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(
|
await editActionMessage(
|
||||||
event,
|
event,
|
||||||
approvalStatusCard({
|
approvalStatusCard({
|
||||||
@@ -127,7 +163,11 @@ export const handleApprovalAction = async (event: ActionEvent) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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);
|
const current = await getApproval(event.value);
|
||||||
await editActionMessage(
|
await editActionMessage(
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ import { createSlackAdapter } from "@chat-adapter/slack";
|
|||||||
import { createPostgresState } from "@chat-adapter/state-pg";
|
import { createPostgresState } from "@chat-adapter/state-pg";
|
||||||
import type { Message, Thread } from "chat";
|
import type { Message, Thread } from "chat";
|
||||||
import { Chat } from "chat";
|
import { Chat } from "chat";
|
||||||
|
import { runMessage } from "./agent/messages.js";
|
||||||
import { handleApprovalAction, postApprovalRequest } from "./approvals/flow.js";
|
import { handleApprovalAction, postApprovalRequest } from "./approvals/flow.js";
|
||||||
import { getSlackWorkspaceId } from "./providers/slack/context.js";
|
|
||||||
import { decrypt } from "./lib/crypto.js";
|
import { decrypt } from "./lib/crypto.js";
|
||||||
import { env } from "./lib/env.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 { 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 {
|
import {
|
||||||
createActionLogger,
|
createActionLogger,
|
||||||
finishLoading,
|
finishLoading,
|
||||||
@@ -15,8 +22,6 @@ import {
|
|||||||
type ReplyTarget,
|
type ReplyTarget,
|
||||||
startLoading,
|
startLoading,
|
||||||
} from "./ui/progress.js";
|
} from "./ui/progress.js";
|
||||||
import { getRecentMessages } from "./providers/slack/threadContext.js";
|
|
||||||
import type { ChatContextMessage } from "./types.js";
|
|
||||||
|
|
||||||
export const chatAdapterNames = ["slack"];
|
export const chatAdapterNames = ["slack"];
|
||||||
|
|
||||||
@@ -66,15 +71,46 @@ const runAndReply = async ({
|
|||||||
threadId: string;
|
threadId: string;
|
||||||
}) => {
|
}) => {
|
||||||
let loading: LoadingState = null;
|
let loading: LoadingState = null;
|
||||||
|
let logger = rootLogger;
|
||||||
try {
|
try {
|
||||||
const workspaceId = getSlackWorkspaceId(raw);
|
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);
|
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);
|
loading = await startLoading(target);
|
||||||
const logAction = createActionLogger(loading);
|
const logAction = createActionLogger(loading);
|
||||||
const output = await runMessage({
|
const output = await runMessage({
|
||||||
installation,
|
installation,
|
||||||
|
logger,
|
||||||
onAction: logAction,
|
onAction: logAction,
|
||||||
recentMessages,
|
recentMessages,
|
||||||
text,
|
text,
|
||||||
@@ -86,6 +122,7 @@ const runAndReply = async ({
|
|||||||
installation,
|
installation,
|
||||||
loading,
|
loading,
|
||||||
logAction,
|
logAction,
|
||||||
|
logger,
|
||||||
output,
|
output,
|
||||||
providerUserId,
|
providerUserId,
|
||||||
target,
|
target,
|
||||||
@@ -94,8 +131,16 @@ const runAndReply = async ({
|
|||||||
|
|
||||||
await finishLoading(target, loading, "Done.");
|
await finishLoading(target, loading, "Done.");
|
||||||
await target.post({ markdown: output.text || "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) {
|
} 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 finishLoading(target, loading, "Request failed.");
|
||||||
await target.post({
|
await target.post({
|
||||||
markdown: "I could not complete that request. Please try again.",
|
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 type { HttpBindings } from "@hono/node-server";
|
||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { chatAdapterNames } from "./bot.js";
|
import { chatAdapterNames } from "./bot.js";
|
||||||
import { env } from "./lib/env.js";
|
import { env } from "./lib/env.js";
|
||||||
|
import { logger } from "./lib/logger.js";
|
||||||
import { registerMcpRoutes } from "./mcp/http.js";
|
import { registerMcpRoutes } from "./mcp/http.js";
|
||||||
import { slackRoutes } from "./providers/slack/routes.js";
|
import { slackRoutes } from "./providers/slack/routes.js";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ registerMcpRoutes(app, {
|
|||||||
"oauth-enabled": true,
|
"oauth-enabled": true,
|
||||||
"oauth-environment": env.MCP_OAUTH_ENVIRONMENT,
|
"oauth-environment": env.MCP_OAUTH_ENVIRONMENT,
|
||||||
"server-url": env.BETTER_AUTH_URL,
|
"server-url": env.BETTER_AUTH_URL,
|
||||||
logger: createConsoleLogger("info"),
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.route("/slack", slackRoutes);
|
app.route("/slack", slackRoutes);
|
||||||
@@ -34,9 +34,12 @@ serve(
|
|||||||
port: env.PORT,
|
port: env.PORT,
|
||||||
},
|
},
|
||||||
({ address, port }) => {
|
({ address, port }) => {
|
||||||
console.log("Chat listening", {
|
logger.info("Chat listening", {
|
||||||
host: `${address}:${port}`,
|
event: "leaf.server_started",
|
||||||
adapters: chatAdapterNames,
|
data: {
|
||||||
|
host: `${address}:${port}`,
|
||||||
|
adapters: chatAdapterNames,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { AutumnLogger } from "@autumn/logging";
|
||||||
import {
|
import {
|
||||||
buildAuthForRequest,
|
buildAuthForRequest,
|
||||||
type ConsoleLogger,
|
|
||||||
createAutumnOperationsMCPServer,
|
createAutumnOperationsMCPServer,
|
||||||
getAuthorizationServerMetadata,
|
getAuthorizationServerMetadata,
|
||||||
getProtectedResourceMetadata,
|
getProtectedResourceMetadata,
|
||||||
@@ -15,7 +16,7 @@ import type { Context, Hono } from "hono";
|
|||||||
export interface McpRouteOptions extends MCPServerFlags {
|
export interface McpRouteOptions extends MCPServerFlags {
|
||||||
readonly "oauth-enabled": boolean;
|
readonly "oauth-enabled": boolean;
|
||||||
readonly "oauth-environment": OAuthEnvironment;
|
readonly "oauth-environment": OAuthEnvironment;
|
||||||
readonly logger: ConsoleLogger;
|
readonly logger: AutumnLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppContext = Context<{ Bindings: HttpBindings }>;
|
type AppContext = Context<{ Bindings: HttpBindings }>;
|
||||||
@@ -23,6 +24,8 @@ type McpPath = "/mcp";
|
|||||||
type McpApp = Hono<{ Bindings: HttpBindings }>;
|
type McpApp = Hono<{ Bindings: HttpBindings }>;
|
||||||
|
|
||||||
export function registerMcpRoutes(app: McpApp, options: McpRouteOptions) {
|
export function registerMcpRoutes(app: McpApp, options: McpRouteOptions) {
|
||||||
|
const mcpServer = createAutumnOperationsMCPServer();
|
||||||
|
|
||||||
app.get("/.well-known/oauth-protected-resource/mcp", (c) =>
|
app.get("/.well-known/oauth-protected-resource/mcp", (c) =>
|
||||||
c.json(getProtectedResourceMetadata(c.req.raw.headers, options, "/mcp")),
|
c.json(getProtectedResourceMetadata(c.req.raw.headers, options, "/mcp")),
|
||||||
);
|
);
|
||||||
@@ -64,14 +67,12 @@ export function registerMcpRoutes(app: McpApp, options: McpRouteOptions) {
|
|||||||
httpPath: path,
|
httpPath: path,
|
||||||
req: c.env.incoming,
|
req: c.env.incoming,
|
||||||
res: c.env.outgoing,
|
res: c.env.outgoing,
|
||||||
options: { serverless: true },
|
options: { sessionIdGenerator: randomUUID },
|
||||||
});
|
});
|
||||||
return RESPONSE_ALREADY_SENT;
|
return RESPONSE_ALREADY_SENT;
|
||||||
};
|
};
|
||||||
|
|
||||||
app.all("/mcp", (c) =>
|
app.all("/mcp", (c) => handleMcp(c, "/mcp", mcpServer));
|
||||||
handleMcp(c, "/mcp", createAutumnOperationsMCPServer()),
|
|
||||||
);
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { verifyChatInstallState } from "@autumn/shared/utils/chatState";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { bot } from "../../bot.js";
|
import { bot } from "../../bot.js";
|
||||||
|
import { logger } from "../../lib/logger.js";
|
||||||
import { getStateSecret, replaceInstallation } from "./installations.js";
|
import { getStateSecret, replaceInstallation } from "./installations.js";
|
||||||
import { exchangeSlackCode, slackErrorUrl, slackSuccessUrl } from "./oauth.js";
|
import { exchangeSlackCode, slackErrorUrl, slackSuccessUrl } from "./oauth.js";
|
||||||
|
|
||||||
@@ -36,25 +37,38 @@ slackRoutes.get("/oauth/callback", async (c) => {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
installedByProviderUserId: oauth.authed_user?.id,
|
installedByProviderUserId: oauth.authed_user?.id,
|
||||||
});
|
});
|
||||||
console.info("[chat:slack] Installed", {
|
logger.info("[chat:slack] Installed", {
|
||||||
orgId: parsedState.orgId,
|
event: "leaf.slack_installed",
|
||||||
workspaceId: oauth.team.id,
|
context: {
|
||||||
workspaceName: oauth.team.name,
|
org_id: parsedState.orgId,
|
||||||
|
slack_workspace_id: oauth.team.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
workspace_name: oauth.team.name,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.redirect(slackSuccessUrl());
|
return c.redirect(slackSuccessUrl());
|
||||||
} catch (error) {
|
} 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"));
|
return c.redirect(slackErrorUrl("Slack install failed"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
slackRoutes.post("/events", (c) => {
|
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);
|
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
|
||||||
return bot.webhooks.slack(c.req.raw);
|
return bot.webhooks.slack(c.req.raw);
|
||||||
});
|
});
|
||||||
|
|
||||||
slackRoutes.post("/interactions", (c) => {
|
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);
|
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
|
||||||
return bot.webhooks.slack(c.req.raw);
|
return bot.webhooks.slack(c.req.raw);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AppEnv, type ChatInstallation } from "@autumn/shared";
|
import { AppEnv, type ChatInstallation } from "@autumn/shared";
|
||||||
|
import type { AutumnLogger } from "@autumn/logging";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const agentOutputSchema = z.preprocess(
|
export const agentOutputSchema = z.preprocess(
|
||||||
@@ -62,6 +63,7 @@ export type SignatureArgs = {
|
|||||||
|
|
||||||
export type BotMessage = {
|
export type BotMessage = {
|
||||||
installation: ChatInstallation;
|
installation: ChatInstallation;
|
||||||
|
logger?: AutumnLogger;
|
||||||
onAction?: (message: string) => Promise<void> | void;
|
onAction?: (message: string) => Promise<void> | void;
|
||||||
recentMessages?: ChatContextMessage[];
|
recentMessages?: ChatContextMessage[];
|
||||||
text: string;
|
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": {
|
"paths": {
|
||||||
"@autumn/shared": ["../../shared/index.ts"],
|
"@autumn/shared": ["../../shared/index.ts"],
|
||||||
"@autumn/shared/*": ["../../shared/*"],
|
"@autumn/shared/*": ["../../shared/*"],
|
||||||
|
"@autumn/logging": ["../../packages/logging/src/index.ts"],
|
||||||
"@autumn/mcp/*": ["../../packages/mcp/*"],
|
"@autumn/mcp/*": ["../../packages/mcp/*"],
|
||||||
"@api/*": ["../../shared/api/*"],
|
"@api/*": ["../../shared/api/*"],
|
||||||
"@models/*": ["../../shared/models/*"],
|
"@models/*": ["../../shared/models/*"],
|
||||||
|
|||||||
23
bun.lock
23
bun.lock
@@ -88,6 +88,7 @@
|
|||||||
"name": "@autumn/leaf",
|
"name": "@autumn/leaf",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@autumn/logging": "workspace:*",
|
||||||
"@autumn/mcp": "workspace:*",
|
"@autumn/mcp": "workspace:*",
|
||||||
"@autumn/shared": "workspace:*",
|
"@autumn/shared": "workspace:*",
|
||||||
"@chat-adapter/slack": "^4.29.0",
|
"@chat-adapter/slack": "^4.29.0",
|
||||||
@@ -284,10 +285,24 @@
|
|||||||
"name": "@autumn/ksuid",
|
"name": "@autumn/ksuid",
|
||||||
"version": "1.0.0",
|
"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": {
|
"packages/mcp": {
|
||||||
"name": "@autumn/mcp",
|
"name": "@autumn/mcp",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@autumn/logging": "workspace:*",
|
||||||
"@autumn/shared": "workspace:*",
|
"@autumn/shared": "workspace:*",
|
||||||
"@axiomhq/js": "^1.6.1",
|
"@axiomhq/js": "^1.6.1",
|
||||||
"@mastra/core": "^1.36.0",
|
"@mastra/core": "^1.36.0",
|
||||||
@@ -722,6 +737,8 @@
|
|||||||
|
|
||||||
"@autumn/leaf": ["@autumn/leaf@workspace:apps/leaf"],
|
"@autumn/leaf": ["@autumn/leaf@workspace:apps/leaf"],
|
||||||
|
|
||||||
|
"@autumn/logging": ["@autumn/logging@workspace:packages/logging"],
|
||||||
|
|
||||||
"@autumn/mcp": ["@autumn/mcp@workspace:packages/mcp"],
|
"@autumn/mcp": ["@autumn/mcp@workspace:packages/mcp"],
|
||||||
|
|
||||||
"@autumn/openapi": ["@autumn/openapi@workspace:packages/openapi"],
|
"@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/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/@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=="],
|
"@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/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/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=="],
|
"@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/atmn-tests/package.json packages/atmn-tests/
|
||||||
COPY packages/autumn-js/package.json packages/autumn-js/
|
COPY packages/autumn-js/package.json packages/autumn-js/
|
||||||
COPY packages/ksuid/package.json packages/ksuid/
|
COPY packages/ksuid/package.json packages/ksuid/
|
||||||
|
COPY packages/logging/package.json packages/logging/
|
||||||
COPY packages/mcp/package.json packages/mcp/
|
COPY packages/mcp/package.json packages/mcp/
|
||||||
COPY packages/openapi/package.json packages/openapi/
|
COPY packages/openapi/package.json packages/openapi/
|
||||||
COPY packages/sdk/package.json packages/sdk/
|
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",
|
"apps/sdk-test",
|
||||||
"packages/atmn",
|
"packages/atmn",
|
||||||
"packages/atmn-tests",
|
"packages/atmn-tests",
|
||||||
|
"packages/logging",
|
||||||
"packages/mcp",
|
"packages/mcp",
|
||||||
"packages/sdk",
|
"packages/sdk",
|
||||||
"packages/autumn-js",
|
"packages/autumn-js",
|
||||||
@@ -115,6 +116,7 @@
|
|||||||
"tb:prod-legacy": "bun scripts/tinybird/index.ts prod-legacy",
|
"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": "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",
|
"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",
|
"trigger:deploy": "bunx trigger.dev deploy",
|
||||||
"setupci": "node scripts/setup/setupci.js",
|
"setupci": "node scripts/setup/setupci.js",
|
||||||
"replicate": "bun scripts/db/replicate.ts",
|
"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"
|
"prepublishOnly": "bun run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@autumn/logging": "workspace:*",
|
||||||
"@autumn/shared": "workspace:*",
|
"@autumn/shared": "workspace:*",
|
||||||
"@axiomhq/js": "^1.6.1",
|
"@axiomhq/js": "^1.6.1",
|
||||||
"@mastra/core": "^1.36.0",
|
"@mastra/core": "^1.36.0",
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ const defaultEndTime = "now";
|
|||||||
const maxRangeMs = ms.days(7);
|
const maxRangeMs = ms.days(7);
|
||||||
const searchMaxRangeMs = ms.hours(1);
|
const searchMaxRangeMs = ms.hours(1);
|
||||||
|
|
||||||
|
type AutumnOrg = { id: string; slug?: string | undefined };
|
||||||
|
|
||||||
let axiomClient: Axiom | null = null;
|
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 = () => {
|
const getAxiomClient = () => {
|
||||||
if (!process.env.AXIOM_ADMIN_TOKEN) {
|
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 = [
|
const cacheKey = [
|
||||||
auth.serverURL ?? "https://api.useautumn.com",
|
auth.serverURL ?? "https://api.useautumn.com",
|
||||||
auth.env,
|
auth.env,
|
||||||
@@ -96,7 +104,7 @@ export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
|
|||||||
String(auth.failOpen),
|
String(auth.failOpen),
|
||||||
].join(":");
|
].join(":");
|
||||||
const cached = orgCache.get(cacheKey);
|
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 client = createAutumnClient(auth);
|
||||||
const response = await fetch(new URL("/v1/organization", client.baseUrl), {
|
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.");
|
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) {
|
if (typeof body.id !== "string" || !body.id) {
|
||||||
throw new Error("Autumn organization response did not include an 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, {
|
orgCache.set(cacheKey, {
|
||||||
orgId: body.id,
|
org,
|
||||||
expiresAt: addMilliseconds(new Date(), ms.minutes(5)),
|
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 = ({
|
export const prepareAxiomQuery = ({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AnalyticsSink } from "./analyticsTypes.js";
|
import type { AnalyticsSink } from "./analyticsTypes.js";
|
||||||
import { createAxiomAnalyticsSink } from "./axiomSink.js";
|
import { createLoggerAnalyticsSink } from "./loggerSink.js";
|
||||||
|
|
||||||
const DEFAULT_DATASET = "leaf";
|
const DEFAULT_DATASET = "leaf";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export const setAnalyticsSink = (sink: AnalyticsSink | null | undefined) => {
|
|||||||
export const getAnalyticsSink = (): AnalyticsSink => {
|
export const getAnalyticsSink = (): AnalyticsSink => {
|
||||||
if (overrideSink !== undefined) return overrideSink ?? noopSink;
|
if (overrideSink !== undefined) return overrideSink ?? noopSink;
|
||||||
if (cachedSink === undefined) {
|
if (cachedSink === undefined) {
|
||||||
cachedSink = createAxiomAnalyticsSink({
|
cachedSink = createLoggerAnalyticsSink({
|
||||||
token: process.env.AXIOM_TOKEN,
|
token: process.env.AXIOM_TOKEN,
|
||||||
orgId: process.env.AXIOM_ORG_ID,
|
orgId: process.env.AXIOM_ORG_ID,
|
||||||
dataset: process.env.MCP_ANALYTICS_DATASET ?? DEFAULT_DATASET,
|
dataset: process.env.MCP_ANALYTICS_DATASET ?? DEFAULT_DATASET,
|
||||||
|
|||||||
@@ -7,21 +7,33 @@
|
|||||||
*/
|
*/
|
||||||
export type McpAnalyticsSurface = "mcp" | "agent";
|
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 = {
|
export type McpAnalyticsEvent = {
|
||||||
event: "mcp.tool_call";
|
event: "mcp.tool_call";
|
||||||
surface: McpAnalyticsSurface;
|
surface: McpAnalyticsSurface;
|
||||||
tool: string;
|
tool: string;
|
||||||
|
/** One-sentence statement of what the caller is trying to do. */
|
||||||
|
intent?: string | undefined;
|
||||||
status: "ok" | "error";
|
status: "ok" | "error";
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
principalId: string;
|
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. */
|
/** HTTP User-Agent of the calling MCP client. Absent for `agent` surface. */
|
||||||
client?: string | undefined;
|
client?: string | undefined;
|
||||||
/** Stateless session grouping: hash(principal + client + time window). */
|
/** MCP transport session id, or fallback hash(principal + client + window). */
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
scopes?: string[] | undefined;
|
context: McpAnalyticsContext;
|
||||||
/** Tool request payload (stored as an Axiom map field). */
|
/** Tool request payload (stored as an Axiom map field). */
|
||||||
input?: unknown;
|
input?: unknown;
|
||||||
/** Tool result payload (stored as an Axiom map field). */
|
/** Tool result payload (stored as an Axiom map field). */
|
||||||
@@ -32,8 +44,8 @@ export type McpAnalyticsEvent = {
|
|||||||
/**
|
/**
|
||||||
* Pluggable destination for analytics events. Implementations must be
|
* Pluggable destination for analytics events. Implementations must be
|
||||||
* non-blocking: `emit` runs on the hot path of every tool call and must never
|
* 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`,
|
* throw or await network I/O inline. Swap this (pino/Axiom, an OTEL exporter,
|
||||||
* an OTEL exporter, a test spy) without touching the instrumentation layer.
|
* a test spy) without touching the instrumentation layer.
|
||||||
*/
|
*/
|
||||||
export interface AnalyticsSink {
|
export interface AnalyticsSink {
|
||||||
emit(event: McpAnalyticsEvent): void;
|
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 type { AutumnMcpAuth } from "../server/auth/auth.js";
|
||||||
import { getAnalyticsSink } from "./analyticsSink.js";
|
import { getAnalyticsSink } from "./analyticsSink.js";
|
||||||
import type { McpAnalyticsSurface } from "./analyticsTypes.js";
|
import type { McpAnalyticsSurface } from "./analyticsTypes.js";
|
||||||
@@ -14,6 +14,8 @@ export const emitMcpToolEvent = ({
|
|||||||
toolId,
|
toolId,
|
||||||
auth,
|
auth,
|
||||||
client,
|
client,
|
||||||
|
transportSessionId,
|
||||||
|
intent,
|
||||||
status,
|
status,
|
||||||
durationMs,
|
durationMs,
|
||||||
input,
|
input,
|
||||||
@@ -24,6 +26,8 @@ export const emitMcpToolEvent = ({
|
|||||||
toolId: string;
|
toolId: string;
|
||||||
auth: AutumnMcpAuth;
|
auth: AutumnMcpAuth;
|
||||||
client: string | undefined;
|
client: string | undefined;
|
||||||
|
transportSessionId?: string | undefined;
|
||||||
|
intent?: string | undefined;
|
||||||
status: "ok" | "error";
|
status: "ok" | "error";
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
input?: unknown;
|
input?: unknown;
|
||||||
@@ -32,33 +36,40 @@ export const emitMcpToolEvent = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const sink = getAnalyticsSink();
|
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 () => {
|
void (async () => {
|
||||||
let orgId = auth.orgId;
|
let orgId = auth.orgId;
|
||||||
if (!orgId) {
|
let orgSlug: string | undefined;
|
||||||
try {
|
try {
|
||||||
orgId = await resolveAutumnOrgId(auth);
|
const org = await resolveAutumnOrg(auth);
|
||||||
} catch {
|
orgId = org.id;
|
||||||
// Best-effort: emit without org_id rather than dropping the event.
|
orgSlug = org.slug;
|
||||||
}
|
} catch {
|
||||||
|
// Best-effort: emit without org context rather than dropping the event.
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
sink.emit({
|
sink.emit({
|
||||||
event: "mcp.tool_call",
|
event: "mcp.tool_call",
|
||||||
surface,
|
surface,
|
||||||
tool: toolId,
|
tool: toolId,
|
||||||
|
intent,
|
||||||
status,
|
status,
|
||||||
durationMs,
|
durationMs,
|
||||||
orgId,
|
|
||||||
principalId: auth.principalId,
|
principalId: auth.principalId,
|
||||||
env: auth.env,
|
|
||||||
client,
|
client,
|
||||||
sessionId: deriveSessionId({
|
sessionId:
|
||||||
principalId: auth.principalId,
|
transportSessionId ??
|
||||||
client,
|
deriveSessionId({
|
||||||
now,
|
principalId: auth.principalId,
|
||||||
}),
|
client,
|
||||||
scopes: auth.scopes,
|
now,
|
||||||
|
}),
|
||||||
|
context: {
|
||||||
|
orgId,
|
||||||
|
orgSlug,
|
||||||
|
env: auth.env,
|
||||||
|
scopes: auth.scopes,
|
||||||
|
},
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -8,5 +8,8 @@ export type {
|
|||||||
McpAnalyticsEvent,
|
McpAnalyticsEvent,
|
||||||
McpAnalyticsSurface,
|
McpAnalyticsSurface,
|
||||||
} from "./analyticsTypes.js";
|
} from "./analyticsTypes.js";
|
||||||
export { createAxiomAnalyticsSink } from "./axiomSink.js";
|
|
||||||
export { instrumentToolsWithAnalytics } from "./instrumentTools.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 { createTool } from "@mastra/core/tools";
|
||||||
import { type AutumnMcpAuth, getAutumnAuth } from "../server/auth/auth.js";
|
import { type AutumnMcpAuth, getAutumnAuth } from "../server/auth/auth.js";
|
||||||
|
import { getIntent } from "../tools/utils/intent.js";
|
||||||
import { isAnalyticsEnabled } from "./analyticsSink.js";
|
import { isAnalyticsEnabled } from "./analyticsSink.js";
|
||||||
import type { McpAnalyticsSurface } from "./analyticsTypes.js";
|
import type { McpAnalyticsSurface } from "./analyticsTypes.js";
|
||||||
import { emitMcpToolEvent } from "./emitToolEvent.js";
|
import { emitMcpToolEvent } from "./emitToolEvent.js";
|
||||||
@@ -7,7 +8,9 @@ import { emitMcpToolEvent } from "./emitToolEvent.js";
|
|||||||
type AnyTool = ReturnType<typeof createTool>;
|
type AnyTool = ReturnType<typeof createTool>;
|
||||||
type ToolContext = Parameters<NonNullable<AnyTool["execute"]>>[1];
|
type ToolContext = Parameters<NonNullable<AnyTool["execute"]>>[1];
|
||||||
|
|
||||||
const getClientFromContext = (context: ToolContext): string | undefined => {
|
const getHeadersFromContext = (
|
||||||
|
context: ToolContext,
|
||||||
|
): Record<string, string | undefined> | undefined => {
|
||||||
const extra = (
|
const extra = (
|
||||||
context as {
|
context as {
|
||||||
mcp?: {
|
mcp?: {
|
||||||
@@ -17,7 +20,19 @@ const getClientFromContext = (context: ToolContext): string | undefined => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
)?.mcp?.extra;
|
)?.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 =>
|
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
|
* 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).
|
* call simply skips analytics (it would have failed in the tool anyway).
|
||||||
*
|
*
|
||||||
* Tools are created fresh per request (see `createAutumnOperationsMCPServer`),
|
* Tools are wrapped once when the MCP server is created. The wrapper keeps no
|
||||||
* so mutating `execute` here carries no shared-state risk.
|
* 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 tools The toolset to instrument (mutated in place and returned).
|
||||||
* @param surface Origin of the calls — `mcp` (external clients) or `agent`
|
* @param surface Origin of the calls — `mcp` (external clients) or `agent`
|
||||||
@@ -59,7 +75,10 @@ export const instrumentToolsWithAnalytics = <
|
|||||||
} catch {
|
} catch {
|
||||||
return original(input as never, context as never);
|
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 {
|
try {
|
||||||
const output = await original(input as never, context as never);
|
const output = await original(input as never, context as never);
|
||||||
emitMcpToolEvent({
|
emitMcpToolEvent({
|
||||||
@@ -67,6 +86,8 @@ export const instrumentToolsWithAnalytics = <
|
|||||||
toolId,
|
toolId,
|
||||||
auth,
|
auth,
|
||||||
client,
|
client,
|
||||||
|
transportSessionId,
|
||||||
|
intent,
|
||||||
status: "ok",
|
status: "ok",
|
||||||
durationMs: Date.now() - started,
|
durationMs: Date.now() - started,
|
||||||
input: extractRequest(input),
|
input: extractRequest(input),
|
||||||
@@ -79,6 +100,8 @@ export const instrumentToolsWithAnalytics = <
|
|||||||
toolId,
|
toolId,
|
||||||
auth,
|
auth,
|
||||||
client,
|
client,
|
||||||
|
transportSessionId,
|
||||||
|
intent,
|
||||||
status: "error",
|
status: "error",
|
||||||
durationMs: Date.now() - started,
|
durationMs: Date.now() - started,
|
||||||
input: extractRequest(input),
|
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);
|
createHash("sha256").update(value).digest("hex").slice(0, 32);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stateless session grouping. The serverless MCP transport issues no
|
* Fallback session grouping. Stateful MCP clients send Mcp-Session-Id; when it
|
||||||
* Mcp-Session-Id, so we synthesize one from the principal + client + a coarse
|
* is absent, synthesize a coarse principal/client bucket so calls from the same
|
||||||
* time bucket — calls from the same client within the window collapse into one
|
* client within the window still collapse into one session.
|
||||||
* session.
|
|
||||||
*/
|
*/
|
||||||
export const deriveSessionId = ({
|
export const deriveSessionId = ({
|
||||||
principalId,
|
principalId,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ms } from "@autumn/shared/unixUtils";
|
import { ms } from "@autumn/shared/unixUtils";
|
||||||
import { addMilliseconds, isFuture } from "date-fns";
|
import { addMilliseconds, isFuture } from "date-fns";
|
||||||
import type { ConsoleLogger } from "../../console-logger.js";
|
|
||||||
import { MCP_OAUTH_SCOPES } from "../../constants.js";
|
import { MCP_OAUTH_SCOPES } from "../../constants.js";
|
||||||
import type { AutumnMcpAuth } from "./auth.js";
|
import type { AutumnMcpAuth } from "./auth.js";
|
||||||
import { OAuthHttpError } from "./utils/errors.js";
|
import { OAuthHttpError } from "./utils/errors.js";
|
||||||
@@ -37,6 +36,10 @@ type ExchangedToken = {
|
|||||||
scopes?: string[] | undefined;
|
scopes?: string[] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AuthLogger = {
|
||||||
|
warning: (message: string, data?: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
const apiKeyCache = new Map<string, ExchangedToken & { expiresAt: Date }>();
|
const apiKeyCache = new Map<string, ExchangedToken & { expiresAt: Date }>();
|
||||||
|
|
||||||
const exchangeOAuthToken = async ({
|
const exchangeOAuthToken = async ({
|
||||||
@@ -132,7 +135,7 @@ export const getAuthorizationServerMetadata = (flags: MCPOAuthFlags) => {
|
|||||||
export const buildAuthForRequest = async (
|
export const buildAuthForRequest = async (
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
flags: MCPOAuthFlags,
|
flags: MCPOAuthFlags,
|
||||||
logger: ConsoleLogger,
|
logger: AuthLogger,
|
||||||
resourcePath = "/mcp",
|
resourcePath = "/mcp",
|
||||||
): Promise<AutumnMcpAuth> => {
|
): Promise<AutumnMcpAuth> => {
|
||||||
const env = getEnvironment({ headers, flags });
|
const env = getEnvironment({ headers, flags });
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
rawLocalPreviewTool,
|
rawLocalPreviewTool,
|
||||||
toTools,
|
toTools,
|
||||||
} from "./utils/factories.js";
|
} from "./utils/factories.js";
|
||||||
|
import { requireIntentOnTools } from "./utils/intent.js";
|
||||||
import type { ConfirmedWriteToolName, ToolDomain } from "./utils/types.js";
|
import type { ConfirmedWriteToolName, ToolDomain } from "./utils/types.js";
|
||||||
|
|
||||||
export { dateToEpochMillisecondsTool } from "./utils/dates.js";
|
export { dateToEpochMillisecondsTool } from "./utils/dates.js";
|
||||||
@@ -62,14 +63,16 @@ const confirmedWrites = domains.flatMap(
|
|||||||
*/
|
*/
|
||||||
export const createRawAutumnOperationTools = () =>
|
export const createRawAutumnOperationTools = () =>
|
||||||
instrumentToolsWithAnalytics({
|
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(operations, operationTool),
|
||||||
...toTools(billingPreviews, (config) =>
|
...toTools(billingPreviews, (config) =>
|
||||||
operationTool({ ...config, endpoint: config.previewEndpoint }),
|
operationTool({ ...config, endpoint: config.previewEndpoint }),
|
||||||
),
|
),
|
||||||
...toTools(localPreviews, rawLocalPreviewTool),
|
...toTools(localPreviews, rawLocalPreviewTool),
|
||||||
...toTools(confirmedWrites, operationTool),
|
...toTools(confirmedWrites, operationTool),
|
||||||
},
|
}),
|
||||||
surface: "mcp",
|
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(
|
await expect(
|
||||||
tool.execute(
|
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,
|
{ mcp: { extra: { authInfo: auth } } } as never,
|
||||||
),
|
),
|
||||||
).resolves.toEqual({ id: "cus_1" });
|
).resolves.toEqual({ id: "cus_1" });
|
||||||
@@ -129,9 +132,12 @@ describe("Autumn operation tools", () => {
|
|||||||
if (!tool.execute) throw new Error("createPlan is not executable");
|
if (!tool.execute) throw new Error("createPlan is not executable");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool.execute({ request: { plan_id: "pro", name: "Pro" } }, {
|
tool.execute(
|
||||||
mcp: { extra: { authInfo: auth } },
|
{ intent: "create a plan", request: { plan_id: "pro", name: "Pro" } },
|
||||||
} as never),
|
{
|
||||||
|
mcp: { extra: { authInfo: auth } },
|
||||||
|
} as never,
|
||||||
|
),
|
||||||
).resolves.toEqual({ id: "pro" });
|
).resolves.toEqual({ id: "pro" });
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
@@ -157,6 +163,7 @@ describe("Autumn operation tools", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
tool.execute(
|
tool.execute(
|
||||||
{
|
{
|
||||||
|
intent: "create a schedule",
|
||||||
request: {
|
request: {
|
||||||
customer_id: "cus_1",
|
customer_id: "cus_1",
|
||||||
phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }],
|
phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }],
|
||||||
@@ -189,7 +196,7 @@ describe("Autumn operation tools", () => {
|
|||||||
throw new Error("previewCreateBalance is not executable");
|
throw new Error("previewCreateBalance is not executable");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool.execute({ request }, {
|
tool.execute({ intent: "preview a balance grant", request }, {
|
||||||
mcp: { extra: { authInfo: auth } },
|
mcp: { extra: { authInfo: auth } },
|
||||||
} as never),
|
} as never),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
@@ -222,6 +229,7 @@ describe("Autumn operation tools", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
tool.execute(
|
tool.execute(
|
||||||
{
|
{
|
||||||
|
intent: "grant a balance",
|
||||||
request: {
|
request: {
|
||||||
customer_id: "cus_1",
|
customer_id: "cus_1",
|
||||||
entity_id: "workspace_1",
|
entity_id: "workspace_1",
|
||||||
@@ -259,6 +267,7 @@ describe("Autumn operation tools", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
tool.execute(
|
tool.execute(
|
||||||
{
|
{
|
||||||
|
intent: "preview a schedule",
|
||||||
request: {
|
request: {
|
||||||
customer_id: "cus_1",
|
customer_id: "cus_1",
|
||||||
phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }],
|
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");
|
if (!tool.execute) throw new Error("listCustomers is not executable");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool.execute({ request: { limit: 5000, search: "charlie" } }, {
|
tool.execute(
|
||||||
mcp: { extra: { authInfo: auth } },
|
{
|
||||||
} as never),
|
intent: "list customers",
|
||||||
|
request: { limit: 5000, search: "charlie" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mcp: { extra: { authInfo: auth } },
|
||||||
|
} as never,
|
||||||
|
),
|
||||||
).resolves.toEqual({ customers: [] });
|
).resolves.toEqual({ customers: [] });
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
@@ -317,9 +332,15 @@ describe("Autumn operation tools", () => {
|
|||||||
if (!tool.execute) throw new Error("previewAttach is not executable");
|
if (!tool.execute) throw new Error("previewAttach is not executable");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool.execute({ request: { customer_id: "cus_1", plan_id: "pro" } }, {
|
tool.execute(
|
||||||
mcp: { extra: { authInfo: auth } },
|
{
|
||||||
} as never),
|
intent: "preview an attach",
|
||||||
|
request: { customer_id: "cus_1", plan_id: "pro" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mcp: { extra: { authInfo: auth } },
|
||||||
|
} as never,
|
||||||
|
),
|
||||||
).resolves.toEqual({ total: 50 });
|
).resolves.toEqual({ total: 50 });
|
||||||
await expect(claimLatestPendingAction(auth)).rejects.toThrow(
|
await expect(claimLatestPendingAction(auth)).rejects.toThrow(
|
||||||
"No pending",
|
"No pending",
|
||||||
@@ -346,9 +367,15 @@ describe("Autumn operation tools", () => {
|
|||||||
if (!tool.execute) throw new Error("attach is not executable");
|
if (!tool.execute) throw new Error("attach is not executable");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool.execute({ request: { customer_id: "cus_1", plan_id: "pro" } }, {
|
tool.execute(
|
||||||
mcp: { extra: { authInfo: auth } },
|
{
|
||||||
} as never),
|
intent: "attach a plan",
|
||||||
|
request: { customer_id: "cus_1", plan_id: "pro" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mcp: { extra: { authInfo: auth } },
|
||||||
|
} as never,
|
||||||
|
),
|
||||||
).resolves.toEqual({ ok: true });
|
).resolves.toEqual({ ok: true });
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
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/*"],
|
"@api/*": ["../../shared/api/*"],
|
||||||
"@models/*": ["../../shared/models/*"],
|
"@models/*": ["../../shared/models/*"],
|
||||||
"@utils/*": ["../../shared/utils/*"],
|
"@utils/*": ["../../shared/utils/*"],
|
||||||
|
"@autumn/logging": ["../logging/src/index.ts"],
|
||||||
"@autumn/ksuid": ["../ksuid/src/index.ts"]
|
"@autumn/ksuid": ["../ksuid/src/index.ts"]
|
||||||
},
|
},
|
||||||
"useUnknownInCatchVariables": true,
|
"useUnknownInCatchVariables": true,
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Idempotently provisions the Axiom `leaf` dataset used for MCP usage
|
* Idempotently provisions the Axiom `leaf` dataset used for Leaf runtime logs
|
||||||
* analytics (events emitted from packages/mcp `tool.execute`), and configures
|
* and MCP usage analytics, and configures its map fields.
|
||||||
* its map fields.
|
|
||||||
*
|
*
|
||||||
* Map fields ("vacuum" the unpredictable nested payloads into a single column):
|
* Map fields ("vacuum" the unpredictable nested payloads into a single column):
|
||||||
* MCP tool `input`/`output` payloads have an open-ended shape — every distinct
|
* Tool payloads, req/res bodies, and per-log details have open-ended shape.
|
||||||
* arg key would otherwise become its own mapped field and quickly blow Axiom's
|
* Every distinct top-level key would otherwise become its own mapped field and
|
||||||
* per-dataset field limit. Declaring `input` and `output` as map fields stores
|
* quickly blow Axiom's per-dataset field limit. These map fields keep nested
|
||||||
* their nested keys inside one field each, so they never count toward the limit
|
* keys inside one field each while staying queryable.
|
||||||
* while staying queryable (e.g. `where input.customer_id == '...'`).
|
|
||||||
*
|
*
|
||||||
* Run via the Axiom CLI (resolves AXIOM_ADMIN_TOKEN from infisical):
|
* Run via the Axiom CLI (resolves AXIOM_ADMIN_TOKEN from infisical):
|
||||||
* bun axiom create-leaf # dev
|
* bun axiom create-leaf # dev
|
||||||
@@ -17,17 +15,25 @@
|
|||||||
* Notes:
|
* Notes:
|
||||||
* - AXIOM_ADMIN_TOKEN must be a personal API token with dataset create/update
|
* - AXIOM_ADMIN_TOKEN must be a personal API token with dataset create/update
|
||||||
* scope, NOT the `xaat-` ingest token used at runtime.
|
* scope, NOT the `xaat-` ingest token used at runtime.
|
||||||
* - Safe to re-run: dataset creation tolerates "already exists", and the map
|
* - Safe to re-run: dataset creation tolerates "already exists", and existing
|
||||||
* field list is declared via PUT (full replace), so re-running converges.
|
* map fields are read before missing fields are created.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const AXIOM_BASE = "https://api.axiom.co/v2";
|
const AXIOM_BASE = "https://api.axiom.co/v2";
|
||||||
const DATASET = "leaf";
|
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
|
// Nested, open-ended payloads stored as map fields to stay under the field
|
||||||
// limit. Keep this list minimal — only genuinely high-cardinality objects.
|
// 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) => ({
|
const authHeaders = (token: string) => ({
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
@@ -59,7 +65,42 @@ const createDataset = async (token: string) => {
|
|||||||
throw new Error(`Failed to create dataset: ${res.status} ${text}`);
|
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(
|
const res = await fetch(
|
||||||
`${AXIOM_BASE}/datasets/${encodeURIComponent(DATASET)}/mapfields`,
|
`${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();
|
const text = await res.text();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
existing.add(name);
|
||||||
console.log(` + map field: ${name}`);
|
console.log(` + map field: ${name}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/exist/i.test(text)) {
|
if (/exist/i.test(text)) {
|
||||||
|
existing.add(name);
|
||||||
console.log(` = map field: ${name} (already set)`);
|
console.log(` = map field: ${name} (already set)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -84,8 +127,9 @@ const setMapField = async (token: string, name: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setMapFields = async (token: string) => {
|
const setMapFields = async (token: string) => {
|
||||||
|
const existing = await getMapFields(token);
|
||||||
for (const name of MAP_FIELDS) {
|
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