From b4f72f0bc0f98e3c09aa2f5b3a21219e68513e51 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 11 May 2026 19:43:06 +0100 Subject: [PATCH] =?UTF-8?q?feat(workbench):=20=F0=9F=8E=B8=20add=20Axiom-b?= =?UTF-8?q?acked=20API=20endpoint=20for=20customer=20request=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /workbench/requests gated on Scopes.Superuser. Queries the express Axiom dataset for HTTP request logs filtered by org_slug, env, and customer_id over a 7-day window. Returns the full raw event plus a normalised projection. New external/axiom/ module wraps the singleton client and APL query builder with escapeApl. --- server/package.json | 1 + server/src/external/axiom/aplUtils.ts | 74 +++++++++ server/src/external/axiom/initAxiom.ts | 20 +++ .../handlers/handleListRequestLogs.ts | 143 ++++++++++++++++++ .../src/internal/workbench/workbenchRouter.ts | 7 + server/src/routers/internalRouter.ts | 2 + 6 files changed, 247 insertions(+) create mode 100644 server/src/external/axiom/aplUtils.ts create mode 100644 server/src/external/axiom/initAxiom.ts create mode 100644 server/src/internal/workbench/handlers/handleListRequestLogs.ts create mode 100644 server/src/internal/workbench/workbenchRouter.ts diff --git a/server/package.json b/server/package.json index f3ec18c57..fbee53607 100644 --- a/server/package.json +++ b/server/package.json @@ -52,6 +52,7 @@ "@aws-sdk/client-s3": "^3.1017.0", "@aws-sdk/client-scheduler": "^3.1004.0", "@aws-sdk/client-sqs": "^3.958.0", + "@axiomhq/js": "^1.6.1", "@axiomhq/pino": "^1.3.1", "@better-auth/dash": "catalog:", "@better-auth/oauth-provider": "catalog:", diff --git a/server/src/external/axiom/aplUtils.ts b/server/src/external/axiom/aplUtils.ts new file mode 100644 index 000000000..faea744dd --- /dev/null +++ b/server/src/external/axiom/aplUtils.ts @@ -0,0 +1,74 @@ +export const escapeApl = (value: string): string => + value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + +export type StatusBucket = "all" | "2xx" | "4xx" | "5xx"; +export type HttpMethodFilter = + | "all" + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE"; + +const statusBucketClause = (bucket: StatusBucket): string | null => { + switch (bucket) { + case "2xx": + return "statusCode >= 200 and statusCode < 300"; + case "4xx": + return "statusCode >= 400 and statusCode < 500"; + case "5xx": + return "statusCode >= 500 and statusCode < 600"; + default: + return null; + } +}; + +export const buildRequestLogsQuery = ({ + orgSlug, + env, + customerId, + method, + statusBucket, + search, + limit = 200, + rangeDays = 7, +}: { + orgSlug: string; + env: string; + customerId: string; + method?: HttpMethodFilter; + statusBucket?: StatusBucket; + search?: string; + limit?: number; + rangeDays?: number; +}): string => { + const filters: string[] = [ + `_time > ago(${rangeDays}d)`, + `isnotnull(statusCode)`, + `isnotnull(['req.url'])`, + `(['context.org_slug'] == '${escapeApl(orgSlug)}' or orgSlug == '${escapeApl(orgSlug)}')`, + `(['context.env'] == '${escapeApl(env)}' or env == '${escapeApl(env)}')`, + `(['req.customer_id'] == '${escapeApl(customerId)}' or customer_id == '${escapeApl(customerId)}')`, + ]; + + if (method && method !== "all") { + filters.push(`['req.method'] == '${escapeApl(method)}'`); + } + + const statusClause = statusBucketClause(statusBucket ?? "all"); + if (statusClause) filters.push(statusClause); + + if (search?.trim()) { + const needle = escapeApl(search.trim()); + filters.push( + `(['req.url'] contains '${needle}' or msg contains '${needle}')`, + ); + } + + const wheres = filters.map((f) => `| where ${f}`).join("\n"); + + return `['express'] +${wheres} +| order by _time desc +| limit ${limit}`; +}; diff --git a/server/src/external/axiom/initAxiom.ts b/server/src/external/axiom/initAxiom.ts new file mode 100644 index 000000000..d6445f156 --- /dev/null +++ b/server/src/external/axiom/initAxiom.ts @@ -0,0 +1,20 @@ +import { Axiom } from "@axiomhq/js"; + +const AXIOM_ADMIN_TOKEN = process.env.AXIOM_ADMIN_TOKEN; +const AXIOM_ORG_ID = process.env.AXIOM_ORG_ID; + +export const axiomClient: Axiom | null = AXIOM_ADMIN_TOKEN + ? new Axiom({ + token: AXIOM_ADMIN_TOKEN, + orgId: AXIOM_ORG_ID, + }) + : null; + +export const getAxiomClient = (): Axiom => { + if (!axiomClient) { + throw new Error("Axiom is not configured (AXIOM_ADMIN_TOKEN missing)"); + } + return axiomClient; +}; + +export const isAxiomConfigured = (): boolean => axiomClient !== null; diff --git a/server/src/internal/workbench/handlers/handleListRequestLogs.ts b/server/src/internal/workbench/handlers/handleListRequestLogs.ts new file mode 100644 index 000000000..002551dd9 --- /dev/null +++ b/server/src/internal/workbench/handlers/handleListRequestLogs.ts @@ -0,0 +1,143 @@ +import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; +import { z } from "zod/v4"; +import { + buildRequestLogsQuery, + type HttpMethodFilter, + type StatusBucket, +} from "@/external/axiom/aplUtils.js"; +import { + getAxiomClient, + isAxiomConfigured, +} from "@/external/axiom/initAxiom.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { CusService } from "@/internal/customers/CusService.js"; + +const ListRequestLogsSchema = z.object({ + customer_id: z.string().min(1), + method: z.enum(["all", "GET", "POST", "PUT", "PATCH", "DELETE"]).optional(), + status: z.enum(["all", "2xx", "4xx", "5xx"]).optional(), + search: z.string().optional(), +}); + +export interface RequestLogEntry { + id: string; + time: string; + statusCode: number; + durationMs: number | null; + method: string | null; + url: string | null; + path: string | null; + reqId: string | null; + ip: string | null; + userAgent: string | null; + customerId: string | null; + msg: string | null; + raw: Record; +} + +const extractPath = (url: string | null | undefined): string | null => { + if (!url) return null; + try { + return new URL(url).pathname; + } catch { + return url; + } +}; + +const pickString = ( + d: Record, + keys: string[], +): string | null => { + for (const k of keys) { + const v = d[k]; + if (typeof v === "string" && v.length > 0) return v; + } + return null; +}; + +const pickNumber = ( + d: Record, + keys: string[], +): number | null => { + for (const k of keys) { + const v = d[k]; + if (typeof v === "number") return v; + } + return null; +}; + +export const handleListRequestLogs = createRoute({ + scopes: [Scopes.Superuser], + body: ListRequestLogsSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { org, env } = ctx; + const { customer_id, method, status, search } = c.req.valid("json"); + + const customer = await CusService.getFull({ + ctx, + idOrInternalId: customer_id, + }); + + if (!customer) { + throw new RecaseError({ + message: "Customer not found", + code: ErrCode.CustomerNotFound, + statusCode: StatusCodes.NOT_FOUND, + }); + } + + if (!isAxiomConfigured()) { + return c.json({ logs: [], unconfigured: true }); + } + + const apl = buildRequestLogsQuery({ + orgSlug: org.slug, + env, + customerId: customer.id ?? customer_id, + method: method as HttpMethodFilter | undefined, + statusBucket: status as StatusBucket | undefined, + search, + }); + + try { + const axiom = getAxiomClient(); + const result = await axiom.query(apl); + const matches = result.matches ?? []; + + const logs: RequestLogEntry[] = matches.map((entry, i) => { + const raw = (entry.data ?? {}) as Record; + const url = pickString(raw, ["req.url", "url"]); + return { + id: pickString(raw, ["req.id", "reqId"]) ?? `${entry._time}-${i}`, + time: entry._time, + statusCode: pickNumber(raw, ["statusCode"]) ?? 0, + durationMs: pickNumber(raw, ["durationMs"]), + method: pickString(raw, ["req.method", "method"]), + url, + path: extractPath(url), + reqId: pickString(raw, ["req.id", "reqId"]), + ip: pickString(raw, ["req.ip_address"]), + userAgent: pickString(raw, ["req.user_agent"]), + customerId: pickString(raw, [ + "req.customer_id", + "customer_id", + "cusId", + ]), + msg: pickString(raw, ["msg", "message"]), + raw, + }; + }); + + return c.json({ logs }); + } catch (err) { + ctx.logger?.error("Axiom workbench query failed", { err }); + throw new RecaseError({ + message: "Failed to query request logs", + code: ErrCode.InternalError, + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + }); + } + }, +}); diff --git a/server/src/internal/workbench/workbenchRouter.ts b/server/src/internal/workbench/workbenchRouter.ts new file mode 100644 index 000000000..c0e4e41bc --- /dev/null +++ b/server/src/internal/workbench/workbenchRouter.ts @@ -0,0 +1,7 @@ +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleListRequestLogs } from "./handlers/handleListRequestLogs.js"; + +export const workbenchRouter = new Hono(); + +workbenchRouter.post("/requests", ...handleListRequestLogs); diff --git a/server/src/routers/internalRouter.ts b/server/src/routers/internalRouter.ts index a792697d9..b86f99c71 100644 --- a/server/src/routers/internalRouter.ts +++ b/server/src/routers/internalRouter.ts @@ -20,6 +20,7 @@ import { pricingAgentRouter } from "../internal/misc/pricingAgent/pricingAgentRo import { savedViewsRouter } from "../internal/misc/savedViews/savedViewsRouter"; import { internalOrgRouter } from "../internal/orgs/orgRouter"; import { internalProductRouter } from "../internal/products/internalProductRouter"; +import { workbenchRouter } from "../internal/workbench/workbenchRouter"; export const internalRouter = new Hono(); @@ -45,6 +46,7 @@ internalRouter.route("/trmnl", internalTrmnlRouter); internalRouter.route("/feedback", feedbackRouter); internalRouter.route("/saved_views", savedViewsRouter); internalRouter.route("/query", internalAnalyticsRouter); +internalRouter.route("/workbench", workbenchRouter); // Autumn SDK handler (requires session auth) if (process.env.AUTUMN_SECRET_KEY) {