feat(workbench): 🎸 add Axiom-backed API endpoint for customer request logs

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.
This commit is contained in:
amianthus
2026-05-11 19:43:06 +01:00
parent 9e37f03b6d
commit b4f72f0bc0
6 changed files with 247 additions and 0 deletions

View File

@@ -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:",

74
server/src/external/axiom/aplUtils.ts vendored Normal file
View File

@@ -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}`;
};

20
server/src/external/axiom/initAxiom.ts vendored Normal file
View File

@@ -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;

View File

@@ -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<string, unknown>;
}
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<string, unknown>,
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<string, unknown>,
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<string, unknown>;
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,
});
}
},
});

View File

@@ -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<HonoEnv>();
workbenchRouter.post("/requests", ...handleListRequestLogs);

View File

@@ -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<HonoEnv>();
@@ -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) {