feat: added otel logging to axiom

This commit is contained in:
John Yeo
2025-06-24 12:53:50 +01:00
parent 6ccf6c402f
commit 5254a9781a
34 changed files with 1663 additions and 459 deletions

View File

@@ -60,6 +60,7 @@ services:
volumes:
- ./server:/app/server
- shared-dist:/app/shared/dist
- server-node-modules:/app/server/node_modules
- root-node-modules:/app/node_modules
environment:
- NODE_ENV=development
@@ -78,6 +79,7 @@ services:
# Mount server source for hot reload (workers use server code)
- ./server:/app/server
- shared-dist:/app/shared/dist
- server-node-modules:/app/server/node_modules
- root-node-modules:/app/node_modules
environment:
- NODE_ENV=development

View File

@@ -46,5 +46,5 @@ CMD ["pnpm", "run", "dev"]
FROM base AS workers
COPY server/ ./server/
WORKDIR /app/server
RUN pnpm install
# RUN pnpm install
CMD ["pnpm", "run", "workers"]

1232
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,15 @@
"@date-fns/utc": "^2.1.0",
"@hyperdx/node-opentelemetry": "^0.8.2",
"@logtail/node": "^0.5.2",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.60.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.202.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.202.0",
"@opentelemetry/sdk-trace-base": "^2.0.1",
"@opentelemetry/sdk-trace-node": "^2.0.1",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.0.42",
"@sentry/node": "^9.30.0",
"@supabase/supabase-js": "^2.46.2",

View File

@@ -37,7 +37,7 @@ export const initLogger = () => {
options: {
colorize: true,
translateTime: "UTC:yyyy-mm-dd HH:MM:ss",
ignore: "pid,hostname,res,context,req,statusCode,worker",
ignore: "pid,hostname,res,statusCode,worker,context,req",
customColors: {
default: "white",
60: "bgRed",
@@ -55,18 +55,18 @@ export const initLogger = () => {
});
}
if (process.env.AXIOM_TOKEN) {
streams.push({
level: "info",
stream: pino.transport({
target: "@axiomhq/pino",
options: {
dataset: "express",
token: "xaat-32eb7e2b-8291-40c3-a5bf-c3a0b233fcf8",
},
}),
});
}
// if (process.env.AXIOM_TOKEN) {
// streams.push({
// level: "info",
// stream: pino.transport({
// target: "@axiomhq/pino",
// options: {
// dataset: "express",
// token: "xaat-32eb7e2b-8291-40c3-a5bf-c3a0b233fcf8",
// },
// }),
// });
// }
const logger = pino.default(
{

View File

@@ -80,7 +80,6 @@ export const createLogger = () => {
info: createLogMethod(basePinoLogger.info.bind(basePinoLogger)),
warn: createLogMethod(basePinoLogger.warn.bind(basePinoLogger)),
error: createLogMethod(basePinoLogger.error.bind(basePinoLogger)),
child: ({
context,
onlyProd = false,

View File

@@ -2,18 +2,11 @@ import {
AppEnv,
CollectionMethod,
CusProductStatus,
ErrCode,
Organization,
} from "@autumn/shared";
import { createStripeCli } from "../utils.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import {
getWebhookLock,
releaseWebhookLock,
} from "@/external/redis/stripeWebhookLocks.js";
import RecaseError from "@/utils/errorUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";

View File

@@ -1,19 +1,21 @@
import { config } from "dotenv";
config();
import "./instrumentation";
import { trace, context } from "@opentelemetry/api";
import http from "http";
import cluster from "cluster";
import os from "os";
import mainRouter from "./internal/mainRouter.js";
import express from "express";
import cors from "cors";
import chalk from "chalk";
import webhooksRouter from "./external/webhooks/webhooksRouter.js";
import { apiRouter } from "./internal/api/apiRouter.js";
import { QueueManager } from "./queue/QueueManager.js";
import { AppEnv, AuthType } from "@autumn/shared";
import { AppEnv } from "@autumn/shared";
import { CacheManager } from "./external/caching/CacheManager.js";
import { logger } from "./external/logtail/logtailUtils.js";
import { createPosthogCli } from "./external/posthog/createPosthogCli.js";
@@ -25,6 +27,8 @@ import { auth } from "./utils/auth.js";
import { checkEnvVars } from "./utils/initUtils.js";
import { initLogger } from "./errors/logger.js";
const tracer = trace.getTracer("express");
checkEnvVars();
const init = async () => {
@@ -75,7 +79,7 @@ const init = async () => {
subscribeToOrgUpdates({ db });
app.use((req: any, res: any, next: any) => {
app.use(async (req: any, res: any, next: any) => {
req.env = req.env = req.headers["app_env"] || AppEnv.Sandbox;
req.db = db;
// req.logtailAll = logtailAll;
@@ -83,49 +87,61 @@ const init = async () => {
req.id = req.headers["rndr-id"] || generateId("local_req");
req.timestamp = Date.now();
const reqContext = {
id: req.id,
env: req.headers["app_env"] || undefined,
method: req.method,
url: req.originalUrl,
body: req.body,
timestamp: req.timestamp,
};
// Create span
const spanName = `${req.method} ${req.originalUrl} - ${req.id}`;
const span = tracer.startSpan(spanName);
span.setAttributes({
req_id: req.id,
method: req.method,
url: req.originalUrl,
});
// Store span on request for potential use in other middleware/handlers
req.span = span;
req.logtail = logger.child({
context: {
req: {
id: req.id,
env: req.env,
method: req.method,
url: req.originalUrl,
body: req.body,
timestamp: req.timestamp,
},
req: reqContext,
},
});
next();
// End span when response finishes
res.on("finish", () => {
span.setAttributes({
"http.response.status_code": res.statusCode,
"http.response.body.size": res.get("content-length") || 0,
"http.response.duration": Date.now() - req.timestamp,
});
span.end();
});
// Run the rest of the request processing within the span's context
context.with(trace.setSpan(context.active(), span), () => {
next();
});
});
app.use("/webhooks", webhooksRouter);
app.use(express.json());
app.use((req: any, res: any, next: any) => {
req.logtail.info(`${req.method} ${req.originalUrl}`);
req.logtail.info(`${req.method} ${req.originalUrl}`, {
context: {
body: req.body,
},
});
next();
});
// app.use((req: any, res, next) => {
// const method = req.method;
// const path = req.url;
// const methodToColor: any = {
// GET: chalk.green,
// POST: chalk.yellow,
// PUT: chalk.blue,
// DELETE: chalk.red,
// PATCH: chalk.magenta,
// };
// const methodColor: any = methodToColor[method] || chalk.gray;
// console.log(`${methodColor(method).padEnd(18)} ${path}`);
// next();
// });
app.use(express.json());
app.use(mainRouter);
app.use("/v1", apiRouter);

View File

@@ -0,0 +1,35 @@
import "dotenv/config";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
// Initialize OTLP trace exporter with the endpoint URL and headers
if (process.env.AXIOM_TOKEN) {
const traceExporter = new OTLPTraceExporter({
url: "https://api.axiom.co/v1/traces",
headers: {
Authorization: `Bearer ${process.env.AXIOM_TOKEN}`,
"X-Axiom-Dataset": "express_otel",
},
});
// Creating a resource to identify your service in traces
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: "express",
});
// Configuring the OpenTelemetry Node SDK
const sdk = new NodeSDK({
spanProcessor: new BatchSpanProcessor(traceExporter),
resource: resource,
instrumentations: [getNodeAutoInstrumentations()],
});
// Starting the OpenTelemetry SDK to begin collecting telemetry data
console.log("Starting OpenTelemetry");
sdk.start();
}

View File

@@ -0,0 +1,22 @@
import { context, trace } from "@opentelemetry/api";
const tracer = trace.getTracer("express");
export const withSpan = <T>({
name,
attributes,
fn,
}: {
name: string;
attributes: Record<string, any>;
fn: () => Promise<T>;
}) => {
const span = tracer.startSpan(name);
span.setAttributes(attributes);
return context.with(trace.setSpan(context.active(), span), async () => {
const result = await fn();
span.end();
return result;
});
};

View File

@@ -66,7 +66,6 @@ export const getBooleanEntitledResult = async ({
allowed,
balance: undefined,
feature,
raw: false,
cusProducts,
allFeatures,
})

View File

@@ -233,7 +233,7 @@ const getCusEntsAndFeatures = async ({
if (!feature) {
throw new RecaseError({
message: "Feature not found",
message: `feature with id ${feature_id} not found`,
code: ErrCode.FeatureNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
@@ -298,7 +298,7 @@ entitledRouter.post("", async (req: any, res: any) => {
if (!customer_id) {
throw new RecaseError({
message: "Customer ID is required",
message: "`customer_id` is required",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
@@ -306,7 +306,7 @@ entitledRouter.post("", async (req: any, res: any) => {
if (!feature_id && !product_id) {
throw new RecaseError({
message: "Feature ID or product ID is required",
message: "`feature_id` or `product_id` is required",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
@@ -315,7 +315,7 @@ entitledRouter.post("", async (req: any, res: any) => {
if (feature_id && product_id) {
throw new RecaseError({
message:
"Provide either feature_id or product_id. Not allowed to provide both.",
"Provide either feature_id or product_id. Not allowed to provide both",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
@@ -442,15 +442,6 @@ entitledRouter.post("", async (req: any, res: any) => {
// 3. If with preview, get preview
let preview = undefined;
if (req.body.with_preview) {
// let withPreview = notNullish(req.body.with_preview);
// if (withPreview !== "raw" && withPreview !== "formatted") {
// throw new RecaseError({
// message: "with_preview must be 'raw' or 'formatted'",
// code: ErrCode.InvalidRequest,
// statusCode: StatusCodes.BAD_REQUEST,
// });
// }
try {
preview = await getCheckPreview({
db,
@@ -458,7 +449,6 @@ entitledRouter.post("", async (req: any, res: any) => {
balance: balanceObj?.balance,
feature: featureToUse!,
cusProducts,
raw: req.body.with_preview === "raw",
allFeatures,
});
} catch (error) {

View File

@@ -21,7 +21,6 @@ export const getCheckPreview = async ({
balance,
feature,
cusProducts,
raw = false,
allFeatures,
}: {
db: DrizzleCli;
@@ -29,7 +28,6 @@ export const getCheckPreview = async ({
balance?: number;
feature: Feature;
cusProducts: FullCusProduct[];
raw?: boolean;
allFeatures: Feature[];
}) => {
if (allowed) {

View File

@@ -19,6 +19,7 @@ import { addTaskToQueue } from "@/queue/queueUtils.js";
import { getEventTimestamp } from "./eventUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { runUpdateUsageTask } from "@/trigger/updateUsageTask.js";
import { logger } from "@/external/logtail/logtailUtils.js";
export const eventsRouter: Router = Router();
export const usageRouter: Router = Router();
@@ -39,17 +40,15 @@ const getCusFeatureAndOrg = async ({
// 1. Get customer
const { org, features } = req;
let [customer] = await Promise.all([
getOrCreateCustomer({
req,
customerId,
customerData,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
let customer = await getOrCreateCustomer({
req,
customerId,
customerData,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
entityId,
entityData: req.body.entity_data,
}),
]);
entityId,
entityData: req.body.entity_data,
});
let feature = features.find((f) => f.id == featureId);
let creditSystems = features.filter(
@@ -136,6 +135,8 @@ export const handleUsageEvent = async ({
entity_id,
idempotency_key,
} = req.body;
const { logtail: logger } = req;
if (!customer_id || !feature_id) {
throw new RecaseError({
message: "customer_id and feature_id are required",
@@ -146,6 +147,8 @@ export const handleUsageEvent = async ({
properties = properties || {};
logger.info(`/track: customer ${customer_id}, feature ${feature_id}`);
const startTime = Date.now();
const { customer, org, feature, creditSystems } = await getCusFeatureAndOrg({
req,
customerId: customer_id,
@@ -153,6 +156,8 @@ export const handleUsageEvent = async ({
customerData: customer_data,
entityId: entity_id,
});
logger.info(`/track: get customer took ${Date.now() - startTime}ms`);
const startTime2 = Date.now();
let newEvent = await createAndInsertEvent({
req,
@@ -163,6 +168,7 @@ export const handleUsageEvent = async ({
properties,
idempotencyKey: idempotency_key,
});
logger.info(`/track: insert event took ${Date.now() - startTime2}ms`);
const features = [feature, ...creditSystems];

View File

@@ -15,7 +15,11 @@ import { StatusCodes } from "http-status-codes";
import { and, eq, or, sql } from "drizzle-orm";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getFullCusQuery } from "./getFullCusQuery.js";
import { flipProductResults } from "./cusUtils/cusUtils.js";
import { trace } from "@opentelemetry/api";
import { withSpan } from "../analytics/tracer/spanUtils.js";
const tracer = trace.getTracer("express");
export class CusService {
static async getFull({
db,
@@ -47,51 +51,61 @@ export class CusService {
const includeInvoices = expand?.includes(CusExpand.Invoices) || false;
const withTrialsUsed = expand?.includes(CusExpand.TrialsUsed) || false;
const query = getFullCusQuery(
idOrInternalId,
orgId,
env,
inStatuses,
includeInvoices,
withEntities,
withTrialsUsed,
withSubs,
entityId,
);
return withSpan<FullCustomer>({
name: "CusService.getFull",
attributes: {
idOrInternalId,
entityId,
orgId,
env,
inStatuses,
withEntities,
withSubs,
},
fn: async () => {
const query = getFullCusQuery(
idOrInternalId,
orgId,
env,
inStatuses,
includeInvoices,
withEntities,
withTrialsUsed,
withSubs,
entityId,
);
let result = await db.execute(query);
let result = await db.execute(query);
if (!result || result.length == 0) {
if (allowNotFound) {
// @ts-ignore
return null;
}
if (!result || result.length == 0) {
if (allowNotFound) {
// @ts-ignore
return null as FullCustomer;
}
throw new RecaseError({
message: `Customer ${idOrInternalId} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
throw new RecaseError({
message: `Customer ${idOrInternalId} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
let data = result[0];
data.created_at = Number(data.created_at);
let data = result[0];
data.created_at = Number(data.created_at);
for (const product of data.customer_products as FullCusProduct[]) {
if (!product.customer_prices) {
product.customer_prices = [];
}
for (const product of data.customer_products as FullCusProduct[]) {
if (!product.customer_prices) {
product.customer_prices = [];
}
if (!product.customer_entitlements) {
product.customer_entitlements = [];
}
}
if (!product.customer_entitlements) {
product.customer_entitlements = [];
}
}
// data.invoices = data.invoices || [];
// data.subscriptions = data.subscriptions || [];
// data.trials_used = data.trials_used || [];
return data as FullCustomer;
return data as FullCustomer;
},
});
}
static async get({

View File

@@ -52,6 +52,10 @@ export const getOrCreateCustomer = async ({
const { db, org, features, env, logtail: logger } = req;
if (!withEntities) {
withEntities = expand?.includes(CusExpand.Entities) || false;
}
if (!skipGet) {
customer = await CusService.getFull({
db,

View File

@@ -2,31 +2,19 @@ import { CusService } from "@/internal/customers/CusService.js";
import RecaseError from "@/utils/errorUtils.js";
import {
AppEnv,
AttachScenario,
BillingInterval,
CreateCustomer,
CreateCustomerSchema,
CusProductStatus,
Customer,
ErrCode,
FullProduct,
Organization,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { generateId, notNullish } from "@/utils/genUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { handleAddProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.js";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import {
initProductInStripe,
isFreeProduct,
} from "@/internal/products/productUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { initProductInStripe } from "@/internal/products/productUtils.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { ActionRequest, ExtendedRequest } from "@/utils/models/Request.js";
import { addCustomerCreatedTask } from "@/internal/analytics/handlers/handleCustomerCreated.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { createNewCustomer } from "../cusUtils/createNewCustomer.js";
export const initStripeCusAndProducts = async ({

View File

@@ -14,30 +14,29 @@ export const handleGetCustomer = async (req: any, res: any) =>
action: "get customer",
handler: async () => {
let customerId = req.params.customer_id;
let { orgId, env, db } = req;
let { env, db, logtail: logger, org, features } = req;
let { expand } = req.query;
let expandArray = parseCusExpand(expand);
const [features, org, customer] = await Promise.all([
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
CusService.getFull({
db,
idOrInternalId: customerId,
orgId: orgId,
env: env,
inStatuses: [
CusProductStatus.Active,
CusProductStatus.PastDue,
CusProductStatus.Scheduled,
],
withEntities: true,
// withEntities: expandArray.includes(CusExpand.Entities),
expand: expandArray,
allowNotFound: true,
}),
]);
logger.info(`getting customer ${customerId} for org ${org.slug}`);
const startTime = Date.now();
const customer = await CusService.getFull({
db,
idOrInternalId: customerId,
orgId: org.id,
env: env,
inStatuses: [
CusProductStatus.Active,
CusProductStatus.PastDue,
CusProductStatus.Scheduled,
],
withEntities: true,
// withEntities: expandArray.includes(CusExpand.Entities),
expand: expandArray,
allowNotFound: true,
});
logger.info(`get customer took ${Date.now() - startTime}ms`);
if (!customer) {
req.logtail.warn(

View File

@@ -60,6 +60,7 @@ mainRouter.use(
headers: {
cookie: req.headers.cookie,
"Content-Type": "application/json",
origin: req.get("origin"),
},
});
return client as any;

View File

@@ -50,6 +50,15 @@ export const analyticsMiddleware = async (req: any, res: any, next: any) => {
req?.body?.customer_id || parseCustomerIdFromUrl(req.originalUrl),
};
if (req.span) {
req.span.setAttributes({
org_id: req.org?.id,
org_slug: req.org?.slug,
env: req.env,
customer_id: reqContext.customer_id,
});
}
req.logtail = req.logtail.child({
context: {
context: reqContext,

View File

@@ -3,6 +3,27 @@ import { verifyKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
import { verifyBearerPublishableKey } from "./publicAuthMiddleware.js";
import { AuthType, ErrCode } from "@autumn/shared";
import { floatToVersion } from "@/utils/versionUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { dashboardOrigins } from "@/utils/constants.js";
const verifyApiVersion = (version: string) => {
let versionFloat = parseFloat(version);
let apiVersion = floatToVersion(versionFloat);
if (isNaN(versionFloat) || !apiVersion) {
throw new RecaseError({
message: `${version} is not a valid API version`,
code: ErrCode.InvalidApiVersion,
statusCode: 400,
});
}
return apiVersion;
};
const maskApiKey = (apiKey: string) => {
return apiKey.slice(0, 15) + apiKey.slice(15).replace(/./g, "*");
};
export const verifySecretKey = async (req: any, res: any, next: any) => {
const authHeader =
@@ -12,159 +33,92 @@ export const verifySecretKey = async (req: any, res: any, next: any) => {
const version = req.headers["x-api-version"];
if (version) {
let versionFloat = parseFloat(version);
if (isNaN(versionFloat)) {
return {
error: ErrCode.InvalidApiVersion,
fallback: false,
statusCode: 400,
};
}
let apiVersion = floatToVersion(versionFloat);
if (!apiVersion) {
return {
error: ErrCode.InvalidApiVersion,
fallback: false,
statusCode: 400,
};
}
req.apiVersion = apiVersion;
req.apiVersion = verifyApiVersion(version);
}
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return {
error: ErrCode.NoAuthHeader,
fallback: true,
};
let origin = req.get("origin");
if (dashboardOrigins.includes(origin)) {
return withOrgAuth(req, res, next);
} else {
throw new RecaseError({
message: "Secret key not found in Authorization header",
code: ErrCode.NoSecretKey,
statusCode: 401,
});
}
}
const apiKey = authHeader.split(" ")[1];
if (!apiKey.startsWith("am_")) {
return {
error: ErrCode.InvalidAuthHeader,
fallback: true,
statusCode: null,
};
throw new RecaseError({
message: "Invalid secret key",
code: ErrCode.InvalidSecretKey,
statusCode: 401,
});
}
if (apiKey.startsWith("am_pk")) {
return await verifyBearerPublishableKey(apiKey, req, res, next);
}
// Try verify via Autumn
const { valid, data } = await verifyKey({
db: req.db,
key: apiKey,
});
try {
const { valid, data } = await verifyKey({
db: req.db,
key: apiKey,
if (!valid || !data) {
throw new RecaseError({
message: "Invalid secret key",
code: ErrCode.InvalidSecretKey,
statusCode: 401,
});
if (valid && data) {
let { org, features, env } = data;
req.orgId = org.id;
req.env = env;
req.minOrg = {
id: org.id,
slug: org.slug,
};
req.org = org;
req.features = features;
req.authType = AuthType.SecretKey;
next();
return {
error: null,
fallback: null,
statusCode: null,
};
} else {
logger.info(`Autumn API verification failed`);
return {
error: ErrCode.FailedToVerifySecretKey,
fallback: true,
statusCode: 401,
};
}
} catch (error) {
try {
logger.error("Failed to fetch key from Autumn", error);
} catch (error) {
console.error("(log failed) Failed to fetch key from Autumn", error);
}
return {
error: ErrCode.FailedToFetchKeyFromAutumn,
fallback: true,
statusCode: 500,
};
}
let { org, features, env } = data;
req.orgId = org.id;
req.env = env;
req.minOrg = {
id: org.id,
slug: org.slug,
};
req.org = org;
req.features = features;
req.authType = AuthType.SecretKey;
next();
};
export const apiAuthMiddleware = async (req: any, res: any, next: any) => {
// 1. Verify secret key
const logger = req.logtail;
try {
const { error, fallback, statusCode } = await verifySecretKey(
req,
res,
next,
);
await verifySecretKey(req, res, next);
if (!error) {
return;
}
if (error && !fallback) {
res.status(statusCode).json({
message: error,
code: error,
});
return;
}
return;
} catch (error: any) {
try {
let logger = req.logtail;
logger.error("Error: verifySecretKey failed", error);
} catch (error) {
console.error("(log failed) Error: verifySecretKey failed", error);
if (error instanceof RecaseError) {
if (error.code === ErrCode.InvalidSecretKey) {
let apiKey = req.headers["authorization"]?.split(" ")[1];
error.message = `Invalid secret key: ${maskApiKey(apiKey)}`;
}
logger.warn(`auth warning: ${error.message}`);
res.status(error.statusCode).json({
message: error.message,
code: error.code,
});
} else {
logger.error(`auth error: ${error.message}`, {
error,
});
res.status(500).json({
message: `Failed to verify secret key: ${error.message}`,
code: ErrCode.InternalError,
});
}
res.status(500).json({
message: "Failed to verify secret key -- internal server error",
code: ErrCode.FailedToVerifySecretKey,
});
return;
}
withOrgAuth(req, res, next);
// // 2. Verify publishable key (through x-publishable-key header)
// try {
// const { error, fallback, statusCode } = await verifyPublishableKey(
// req,
// res,
// next
// );
// if (!error) {
// return;
// }
// if (error && !fallback) {
// res.status(statusCode).json({
// message: error,
// code: error,
// });
// return;
// }
// } catch (error) {
// console.log("Error: verifyPublishableKey failed", error);
// res.status(500).json({
// message: "Failed to verify publishable key -- internal server error",
// code: ErrCode.FailedToVerifyPublishableKey,
// });
// return;
// }
};

View File

@@ -13,4 +13,7 @@ export const ADMIN_USER_IDs =
? ["user_2tMgAiPsQzX8JTHjZZh9m0VdvUv", "user_2sB3tBXsnVVLlTKliQIqvvM2xfB"]
: ["user_2rypooIKyMQx81vMS8FFGx24UHU"];
// console.log(ADMIN_USER_IDs);
export const dashboardOrigins = [
"http://localhost:3000",
"https://app.useautumn.com",
];

View File

@@ -1,5 +1,9 @@
export const ErrCode = {
// Auth
InvalidApiVersion: "invalid_api_version",
NoSecretKey: "no_secret_key",
InvalidSecretKey: "invalid_secret_key",
NoAuthHeader: "no_auth_header",
InvalidAuthHeader: "invalid_auth_header",
FailedToVerifySecretKey: "failed_to_verify_secret_key",
@@ -10,7 +14,6 @@ export const ErrCode = {
GetOrgFromPublishableKeyFailed: "get_org_from_publishable_key_failed",
EndpointNotPublic: "endpoint_not_public",
FailedToVerifyPublishableKey: "failed_to_verify_publishable_key",
InvalidApiVersion: "invalid_api_version",
// General
InvalidInputs: "invalid_inputs",

View File

@@ -13,12 +13,17 @@ import { ArrowUpRightFromSquare, PanelLeft, PanelRight } from "lucide-react";
import { AutumnProvider } from "autumn-js/react";
import { useSession } from "@/lib/auth-client";
import { CustomToaster } from "@/components/general/CustomToaster";
import { SidebarContext, useSidebarContext } from "@/views/main-sidebar/SidebarContext";
import {
SidebarContext,
useSidebarContext,
} from "@/views/main-sidebar/SidebarContext";
export function MainLayout() {
const env = useEnv();
const { data, isPending } = useSession();
const [sidebarState, setSidebarState] = useState<"expanded" | "collapsed">("expanded");
const [sidebarState, setSidebarState] = useState<"expanded" | "collapsed">(
"expanded",
);
const navigate = useNavigate();
const posthog = usePostHog();
@@ -39,7 +44,9 @@ export function MainLayout() {
// 1. If not loaded, show loading screen
if (isPending) {
return (
<SidebarContext.Provider value={{ state: sidebarState, setState: setSidebarState }}>
<SidebarContext.Provider
value={{ state: sidebarState, setState: setSidebarState }}
>
<div className="w-screen h-screen flex bg-stone-100">
<MainSidebar />
<div className="w-full h-screen flex flex-col overflow-hidden py-3 pr-3">
@@ -88,7 +95,9 @@ export function MainLayout() {
return (
<AutumnProvider backendUrl={import.meta.env.VITE_BACKEND_URL}>
<SidebarContext.Provider value={{ state: sidebarState, setState: setSidebarState }}>
<SidebarContext.Provider
value={{ state: sidebarState, setState: setSidebarState }}
>
<main className="w-screen h-screen flex bg-stone-100">
<CustomToaster />
<MainSidebar />
@@ -104,23 +113,11 @@ const MainContent = () => {
const navigate = useNavigate();
const { state, setState } = useSidebarContext();
const toggleSidebar = () => {
setState(state === "expanded" ? "collapsed" : "expanded");
};
return (
<div className="w-full h-screen flex flex-col justify-center overflow-hidden py-3 pr-3 relative">
<div className="w-full h-full flex flex-col overflow-hidden rounded-lg border">
{/* Toggle Button */}
<Button
variant="outline"
size="sm"
onClick={toggleSidebar}
className="absolute top-4 left-2 z-10 border-none border-0 shadow-none bg-stone-50 hover:bg-stone-100 text-stone-600 hover:text-stone-800 focus:ring-0 focus:outline-none"
>
{state === "expanded" ? <PanelLeft size={16} /> : <PanelRight size={16} />}
</Button>
{env === AppEnv.Sandbox && (
<div className="w-full min-h-10 h-10 bg-amber-100 text-sm flex items-center justify-center relative px-4 text-amber-500 ">
<p className="font-medium font-mono">You&apos;re in sandbox</p>

View File

@@ -29,7 +29,7 @@ import CopyButton from "@/components/general/CopyButton";
const CustomerWithProductsSchema = CustomerSchema.extend({
customer_products: z.array(
CusProductSchema.extend({ product: ProductSchema })
CusProductSchema.extend({ product: ProductSchema }),
),
});
type CustomerWithProducts = z.infer<typeof CustomerWithProductsSchema>;
@@ -53,7 +53,7 @@ export const CustomersTable = ({
// Filter out expired products first
const activeProducts = customer.customer_products.filter(
(cusProduct) => cusProduct.status !== CusProductStatus.Expired
(cusProduct) => cusProduct.status !== CusProductStatus.Expired,
);
if (activeProducts.length === 0) {
@@ -67,7 +67,7 @@ export const CustomersTable = ({
const versionCount = versionCounts[cusProduct.product.id];
const version = cusProduct.product.version;
let prodName = (
const prodName = (
<>
{name}
{versionCount > 1 && (
@@ -172,7 +172,7 @@ export const CustomersTable = ({
<Link
to={getRedirectUrl(
`/customers/${customer.id || customer.internal_id}`,
env
env,
)}
key={index}
className="grid grid-cols-16 gap-2 items-center px-10 w-full text-sm h-8 cursor-default hover:bg-primary/5 text-t2 whitespace-nowrap"
@@ -218,7 +218,7 @@ export const CustomTableCell = ({
className={cn(
colSpan ? `col-span-${colSpan}` : "col-span-3",
"overflow-hidden text-ellipsis pr-1",
className
className,
)}
>
{children}

View File

@@ -227,24 +227,23 @@ function CustomersView({ env }: { env: AppEnv }) {
<CustomersTable customers={data.customers} />
</div>
) : (
<div className="flex flex-col items-center justify-center text-t3 text-sm w-full min-h-[60vh] gap-4">
<img
src="./customer.png"
alt="No customers"
className="w-48 h-48 opacity-60 filter grayscale"
// className="w-48 h-48 opacity-80 filter brightness-0 invert" // this is for dark mode
/>
<span>
{
// Show loading state during search transitions to prevent flash of incorrect message
(paginationLoading || searching)
? "Loading..."
: searchParams.get("q")?.trim()
? "No matching results found. Try a different search."
: "No customers found... yet 😉"
}
</span>
</div>
<div className="flex flex-col px-10 mt-3 text-t3 text-sm w-full min-h-[60vh] gap-4">
{/* <img
src="./customer.png"
alt="No customers"
className="w-48 h-48 opacity-60 filter grayscale"
// className="w-48 h-48 opacity-80 filter brightness-0 invert" // this is for dark mode
/> */}
<span>
{
// Show loading state during search transitions to prevent flash of incorrect message
searchParams.get("q")?.trim()
? "No matching results found. Try a different search."
: "Create your first customer by interacting with an Autumn function via the API."
}
</span>
</div>
)}
</div>
{/* <div className="shrink-0 sticky bottom-0">

View File

@@ -17,15 +17,17 @@ export const ApiKeysView = ({ apiKeys }: any) => {
{apiKeys.length > 0 ? (
<APIKeyTable apiKeys={apiKeys} />
) : (
<div className="flex flex-col items-center justify-center text-t3 text-sm w-full min-h-[60vh] gap-4">
<img
<div
className="flex flex-col px-10 center text-t3 text-sm w-full
min-h-[60vh] gap-4 mt-3"
>
{/* <img
src="./secret.png"
alt="No API Keys"
className="w-48 h-48 opacity-60 filter grayscale"
// className="w-48 h-48 opacity-80 filter brightness-0 invert" // this is for dark mode
/>
<p className="text-sm text-t3 text-center max-w-2xl px-4">
/> */}
<p className="text-sm text-t3">
API keys are used to securely authenticate your requests from your
server. Learn more{" "}
<a
@@ -38,7 +40,6 @@ export const ApiKeysView = ({ apiKeys }: any) => {
.
</p>
</div>
)}
</div>
);

View File

@@ -4,17 +4,27 @@ import { useEnv } from "@/utils/envUtils";
import { cn } from "@/lib/utils";
import { useSidebarContext } from "./SidebarContext";
import { useHotkeys } from "react-hotkeys-hook";
import { Code, Package, Shield, User } from "lucide-react";
import {
Code,
Package,
PanelLeft,
PanelRight,
Shield,
User,
} from "lucide-react";
import { EnvDropdown } from "./EnvDropdown";
import { OrgDropdown } from "./components/OrgDropdown";
import { AdminOnly } from "../admin/components/AdminOnly";
import { Button } from "@/components/ui/button";
export const MainSidebar = () => {
const env = useEnv();
const { state, setState } = useSidebarContext();
useHotkeys(["meta+b", "ctrl+b"], () => {
setState((prev: "expanded" | "collapsed") => (prev == "expanded" ? "collapsed" : "expanded"));
setState((prev: "expanded" | "collapsed") =>
prev == "expanded" ? "collapsed" : "expanded",
);
});
return (
@@ -26,9 +36,29 @@ export const MainSidebar = () => {
: "min-w-[50px] max-w-[50px]",
)}
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 relative">
<Button
variant="outline"
size="sm"
onClick={() => {
setState(state === "expanded" ? "collapsed" : "expanded");
}}
className={cn(
"absolute top-1 right-4 text-t3 hover:bg-stone-200 w-5 h-5 p-0 border-none border-0 shadow-none bg-transparent",
state == "expanded"
? "opacity-100 transition-opacity duration-100"
: "opacity-0 transition-opacity duration-100",
// state == "expanded" ? "top-4" : "top-2",
)}
>
<PanelLeft size={14} />
{/* {state === "expanded" ? (
) : (
<PanelRight size={14} />
)} */}
</Button>
<OrgDropdown />
{/* <SidebarTop /> */}
<EnvDropdown env={env} />
<div className="flex flex-col px-4">
<NavButton

View File

@@ -1,115 +1,9 @@
import { cn } from "@/lib/utils";
import { OrganizationSwitcher, useUser } from "@clerk/clerk-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { useEnv } from "@/utils/envUtils";
import { useSidebarContext } from "./SidebarContext";
import { Check, ChevronLeft, ChevronRight, Copy } from "lucide-react";
import { AdminHover } from "@/components/general/AdminHover";
import { OrgDropdown } from "./components/OrgDropdown";
export const SidebarTop = () => {
const { state, setState } = useSidebarContext();
return (
<div className="px-2">
<OrgDropdown />
{/* <div
className={cn(
"flex items-center w-full",
state == "expanded" ? "justify-between" : "justify-center",
)}
>
<Button
size="sm"
onClick={() => {
setState((prev: string) =>
prev == "expanded" ? "collapsed" : "expanded",
);
}}
variant="ghost"
className="p-0 w-5 h-5 text-t3 m-0"
>
{state == "expanded" ? (
<ChevronLeft size={14} />
) : (
<ChevronRight size={14} />
)}
</Button>
</div> */}
</div>
);
};
const CopyText = ({ text }: { text: string }) => {
const [isHover, setIsHover] = useState(false);
const [isCopied, setIsCopied] = useState(false);
return (
<div className="flex items-center gap-1">
<p
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
className="flex items-center gap-1 font-mono hover:underline cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
navigator.clipboard.writeText(text);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 1000);
}}
>
{text}
</p>
{(isCopied || isHover) && (
<div
onClick={() => {
navigator.clipboard.writeText(text);
setIsCopied(true);
}}
>
{isCopied ? <Check size={10} /> : <Copy size={10} />}
</div>
)}
</div>
);
};
// {
// state == "expanded" && (
// <div className="flex flex-col">
// <div className="flex relative w-full h-7">
// <OrganizationSwitcher
// appearance={{
// elements: {
// organizationSwitcherTrigger: "flex !pl- pr-1 max-w-[160px]",
// },
// }}
// hidePersonal={true}
// skipInvitationScreen={true}
// afterCreateOrganizationUrl="/sandbox/onboarding"
// />
// {organization && (
// <AdminHover
// texts={[
// {
// key: "id",
// value: organization.id,
// },
// {
// key: "slug",
// value: organization.slug || "N/A",
// },
// ]}
// >
// </AdminHover>
// )}
// </div>
// </div>
// );
// }

View File

@@ -28,6 +28,7 @@ import { DropdownMenuGroup } from "@radix-ui/react-dropdown-menu";
import {
ChevronDown,
LogOut,
PanelRight,
Plus,
Settings,
Shield,
@@ -45,9 +46,7 @@ import { useMemberships } from "../org-dropdown/hooks/useMemberships";
import { useSidebarContext } from "../SidebarContext";
import { OrgLogo } from "../org-dropdown/components/OrgLogo";
import { AdminHover } from "@/components/general/AdminHover";
import { NavButton } from "../NavButton";
import { AdminOnly } from "@/views/admin/components/AdminOnly";
import { getBackendErr, notNullish } from "@/utils/genUtils";
import { AdminDropdownItems } from "./AdminDropdownItems";
export const OrgDropdown = () => {
@@ -155,6 +154,19 @@ export const OrgDropdown = () => {
<Plus size={14} />
</div>
</DropdownMenuItem>
{state === "collapsed" && (
<DropdownMenuItem
onClick={(e) => {
setState("expanded");
setDropdownOpen(false);
}}
>
<div className="flex justify-between w-full items-center gap-2 text-t2">
<span>Open Sidebar</span>
<PanelRight size={14} />
</div>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuSub>

View File

@@ -16,7 +16,7 @@ export const OrgLogo = ({ org }: { org: FrontendOrg }) => {
{org.logo ? (
<img src={org.logo} alt={org.name} className="w-full h-full" />
) : (
<span className="w-full h-full flex items-center justify-center bg-gradient-to-r from-purple-600 via-purple-500 to-[#6f47ff] text-white text-xs">
<span className="w-5 h-5 flex items-center justify-center bg-gradient-to-r from-purple-600 via-purple-500 to-[#6f47ff] text-white text-xs">
{firstLetter}
</span>
)}

View File

@@ -47,19 +47,19 @@ export const ProductsTable = ({
<div
className={cn(
"flex flex-col justify-center items-center h-10 px-10 text-t3 min-h-[60vh] gap-4",
"justify-start items-start mt-3",
onboarding && "px-2 mt-4",
)}
>
<img
{/* <img
src="./product.png"
alt="Products"
className="w-48 h-48 opacity-60 filter grayscale"
// className="w-48 h-48 opacity-80 filter brightness-0 invert" // this is for dark mode
/>
<span className="text-center">
Products define the features your customers can access and how much{" "}
<br />
they cost. Create your first product to get started .
/> */}
<span>
Products define the features your customers can access and how
much they cost. Create your first product to get started .
</span>
</div>
)

View File

@@ -64,7 +64,6 @@ export const FeatureConfig = () => {
</React.Fragment>
)}
<AdvancedItemConfig />
{isFeature && (
<div className="flex w-full justify-start transition-all duration-300 ease-in-out overflow-hidden">
<Button
@@ -77,6 +76,7 @@ export const FeatureConfig = () => {
</Button>
</div>
)}
<AdvancedItemConfig />
</>
);
};

View File

@@ -1,8 +1,6 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Plus, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { useProductContext } from "../../../../ProductContext";
import { useProductItemContext } from "../../../ProductItemContext";
import { Feature, FeatureItemSchema, TierInfinite } from "@autumn/shared";
@@ -15,9 +13,6 @@ export default function FeaturePrice() {
const { item, setItem } = useProductItemContext();
const feature = features.find((f: Feature) => f.id == item.feature_id);
const featureName = feature?.name;
const [editBillingUnits, setEditBillingUnits] = useState(false);
const setUsageTier = (index: number, key: string, value: string | number) => {
const newUsageTiers = [...item.tiers];