Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
628 changes: 628 additions & 0 deletions src/cli/commands/logs/index.ts

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js";
import { getDashboardCommand } from "@/cli/commands/dashboard/index.js";
import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js";
import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js";
import { getLogsCommand } from "@/cli/commands/logs/index.js";
import { getCreateCommand } from "@/cli/commands/project/create.js";
import { getDeployCommand } from "@/cli/commands/project/deploy.js";
import { getLinkCommand } from "@/cli/commands/project/link.js";
Expand Down Expand Up @@ -54,5 +55,8 @@ export function createProgram(context: CLIContext): Command {
// Register types command
program.addCommand(getTypesCommand(context), { hidden: true });

// Register logs command
program.addCommand(getLogsCommand(context));

return program;
}
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from "./clients/index.js";
export * from "./config.js";
export * from "./consts.js";
export * from "./errors.js";
export * from "./logs/index.js";
export * from "./project/index.js";
export * from "./resources/index.js";
export * from "./site/index.js";
Expand Down
109 changes: 109 additions & 0 deletions src/core/logs/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Audit logs API functions.
*/

import type { KyResponse } from "ky";
import { base44Client } from "@/core/clients/index.js";
import { ApiError, SchemaValidationError } from "@/core/errors.js";
import { getAppConfig } from "@/core/project/index.js";
import type { AuditLogFilters, AuditLogsResponse } from "./schema.js";
import { AppInfoResponseSchema, AuditLogsResponseSchema } from "./schema.js";

/**
* Fetch the workspace (organization) ID for the current app.
* Required because audit logs are fetched at the workspace level.
*/
export async function getWorkspaceId(): Promise<string> {
const { id: appId } = getAppConfig();

let response: KyResponse;
try {
// GET /api/apps/{app_id} returns app info including organization_id
response = await base44Client.get(`api/apps/${appId}`);
} catch (error) {
throw await ApiError.fromHttpError(error, "fetching app info");
}

const result = AppInfoResponseSchema.safeParse(await response.json());

if (!result.success) {
throw new SchemaValidationError(
"Invalid app info response from server",
result.error
);
}

return result.data.organization_id;
}

/**
* Build the API request body from CLI filter options.
* Transforms camelCase options to snake_case for the API.
*/
function buildRequestBody(
appId: string,
filters: AuditLogFilters
): Record<string, unknown> {
const body: Record<string, unknown> = {
app_id: appId,
order: filters.order ?? "DESC",
limit: filters.limit ?? 50,
};

if (filters.status) {
body.status = filters.status;
}
if (filters.eventTypes && filters.eventTypes.length > 0) {
body.event_types = filters.eventTypes;
}
if (filters.userEmail) {
body.user_email = filters.userEmail;
}
if (filters.since) {
body.start_date = filters.since;
}
if (filters.until) {
body.end_date = filters.until;
}
if (filters.cursorTimestamp) {
body.cursor_timestamp = filters.cursorTimestamp;
}
if (filters.cursorUserEmail) {
body.cursor_user_email = filters.cursorUserEmail;
}

return body;
}

/**
* Fetch audit logs for the current app.
*/
export async function fetchAuditLogs(
filters: AuditLogFilters = {}
): Promise<AuditLogsResponse> {
const { id: appId } = getAppConfig();
const workspaceId = await getWorkspaceId();

let response: KyResponse;
try {
response = await base44Client.post(
`api/workspace/audit-logs/list?workspaceId=${workspaceId}`,
{
json: buildRequestBody(appId, filters),
}
);
} catch (error) {
throw await ApiError.fromHttpError(error, "fetching audit logs");
}

const result = AuditLogsResponseSchema.safeParse(await response.json());

if (!result.success) {
throw new SchemaValidationError(
"Invalid audit logs response from server",
result.error
);
}

return result.data;
}
7 changes: 7 additions & 0 deletions src/core/logs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { fetchAuditLogs, getWorkspaceId } from "./api.js";
export type {
AuditLogEvent,
AuditLogFilters,
AuditLogsResponse,
Pagination,
} from "./schema.js";
74 changes: 74 additions & 0 deletions src/core/logs/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { z } from "zod";

/**
* Single audit log event from the API response.
*/
export const AuditLogEventSchema = z.looseObject({
timestamp: z.string(),
user_email: z.string().nullable(),
workspace_id: z.string(),
app_id: z.string(),
ip: z.string().nullable(),
user_agent: z.string().nullable(),
event_type: z.string(),
status: z.enum(["success", "failure"]),
error_code: z.string().nullable(),
metadata: z.record(z.string(), z.unknown()).nullable(),
});

export type AuditLogEvent = z.infer<typeof AuditLogEventSchema>;

/**
* Pagination cursor for fetching the next page.
*/
export const PaginationCursorSchema = z.object({
timestamp: z.string(),
user_email: z.string(),
});

/**
* Pagination info from the API response.
*/
export const PaginationSchema = z.object({
total: z.number(),
limit: z.number(),
has_more: z.boolean(),
next_cursor: PaginationCursorSchema.nullable(),
});

export type Pagination = z.infer<typeof PaginationSchema>;

/**
* Full audit logs API response.
*/
export const AuditLogsResponseSchema = z.object({
events: z.array(AuditLogEventSchema),
pagination: PaginationSchema,
});

export type AuditLogsResponse = z.infer<typeof AuditLogsResponseSchema>;

/**
* App info response schema (for extracting workspace/organization ID).
*/
export const AppInfoResponseSchema = z.looseObject({
organization_id: z.string(),
});

export type AppInfoResponse = z.infer<typeof AppInfoResponseSchema>;

/**
* CLI filter options (camelCase for TypeScript).
* These are transformed to snake_case when building the API request.
*/
export interface AuditLogFilters {
status?: "success" | "failure";
eventTypes?: string[];
userEmail?: string;
since?: string;
until?: string;
limit?: number;
order?: "ASC" | "DESC";
cursorTimestamp?: string;
cursorUserEmail?: string;
}
68 changes: 67 additions & 1 deletion src/core/resources/function/api.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import type { KyResponse } from "ky";
import { HTTPError } from "ky";
import { getAppClient } from "@/core/clients/index.js";
import { ApiError, SchemaValidationError } from "@/core/errors.js";
import type {
DeployFunctionsResponse,
FunctionLogFilters,
FunctionLogsResponse,
FunctionWithCode,
} from "@/core/resources/function/schema.js";
import { DeployFunctionsResponseSchema } from "@/core/resources/function/schema.js";
import {
DeployFunctionsResponseSchema,
FunctionLogsResponseSchema,
} from "@/core/resources/function/schema.js";

function toDeployPayloadItem(fn: FunctionWithCode) {
return {
Expand Down Expand Up @@ -44,3 +50,63 @@ export async function deployFunctions(

return result.data;
}

// ─── FUNCTION LOGS API ──────────────────────────────────────

/**
* Build query string from filter options.
*/
function buildLogsQueryString(filters: FunctionLogFilters): string {
const params = new URLSearchParams();

if (filters.since) {
params.set("since", filters.since);
}
if (filters.until) {
params.set("until", filters.until);
}
if (filters.limit !== undefined) {
params.set("limit", String(filters.limit));
}

const queryString = params.toString();
return queryString ? `?${queryString}` : "";
}

/**
* Fetch runtime logs for a specific function from Deno Deploy.
*/
export async function fetchFunctionLogs(
functionName: string,
filters: FunctionLogFilters = {}
): Promise<FunctionLogsResponse> {
const appClient = getAppClient();
const queryString = buildLogsQueryString(filters);

let response: KyResponse;
try {
response = await appClient.get(
`functions-mgmt/${functionName}/logs${queryString}`
);
} catch (error) {
if (error instanceof HTTPError && error.response.status === 404) {
throw new ApiError(`Function "${functionName}" not found`, {
statusCode: 404,
cause: error,
hints: [{ message: "Check the function name and try again" }],
});
}
throw await ApiError.fromHttpError(error, "fetching function logs");
}

const result = FunctionLogsResponseSchema.safeParse(await response.json());

if (!result.success) {
throw new SchemaValidationError(
"Invalid function logs response from server",
result.error
);
}

return result.data;
}
37 changes: 37 additions & 0 deletions src/core/resources/function/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,40 @@ export type DeployFunctionsResponse = z.infer<
export type FunctionWithCode = Omit<BackendFunction, "files"> & {
files: FunctionFile[];
};

// ─── FUNCTION LOGS SCHEMAS ──────────────────────────────────

/**
* Log level from Deno Deploy runtime.
*/
export const LogLevelSchema = z.enum(["log", "info", "warn", "error", "debug"]);

export type LogLevel = z.infer<typeof LogLevelSchema>;

/**
* Single log entry from the function runtime (Deno Deploy).
*/
export const FunctionLogEntrySchema = z.object({
time: z.string(),
level: LogLevelSchema,
message: z.string(),
});

export type FunctionLogEntry = z.infer<typeof FunctionLogEntrySchema>;

/**
* Response from the function logs API - array of log entries.
*/
export const FunctionLogsResponseSchema = z.array(FunctionLogEntrySchema);

export type FunctionLogsResponse = z.infer<typeof FunctionLogsResponseSchema>;

/**
* CLI filter options for function logs.
*/
export interface FunctionLogFilters {
since?: string;
until?: string;
level?: LogLevel;
limit?: number;
}
Loading
Loading