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:
@@ -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
74
server/src/external/axiom/aplUtils.ts
vendored
Normal 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
20
server/src/external/axiom/initAxiom.ts
vendored
Normal 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;
|
||||
143
server/src/internal/workbench/handlers/handleListRequestLogs.ts
Normal file
143
server/src/internal/workbench/handlers/handleListRequestLogs.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
7
server/src/internal/workbench/workbenchRouter.ts
Normal file
7
server/src/internal/workbench/workbenchRouter.ts
Normal 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);
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user