chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1GetLogContract } from '@/lib/api/contracts/v1/logs'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import {
|
||||
checkRateLimit,
|
||||
createRateLimitResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1LogDetailsAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'logs-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1GetLogContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid log ID' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
workspaceId: workflowExecutionLogs.workspaceId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
|
||||
level: workflowExecutionLogs.level,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
costTotal: workflowExecutionLogs.costTotal,
|
||||
files: workflowExecutionLogs.files,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
workflowFolderId: workflow.folderId,
|
||||
workflowUserId: workflow.userId,
|
||||
workflowWorkspaceId: workflow.workspaceId,
|
||||
workflowCreatedAt: workflow.createdAt,
|
||||
workflowUpdatedAt: workflow.updatedAt,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.where(eq(workflowExecutionLogs.id, id))
|
||||
.limit(1)
|
||||
|
||||
const log = rows[0]
|
||||
if (!log) {
|
||||
return NextResponse.json({ error: 'Log not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, log.workspaceId)
|
||||
if (accessError) {
|
||||
return NextResponse.json({ error: 'Log not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workflowSummary = {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName || 'Deleted Workflow',
|
||||
description: log.workflowDescription,
|
||||
folderId: log.workflowFolderId,
|
||||
userId: log.workflowUserId,
|
||||
workspaceId: log.workflowWorkspaceId,
|
||||
createdAt: log.workflowCreatedAt,
|
||||
updatedAt: log.workflowUpdatedAt,
|
||||
deleted: !log.workflowName,
|
||||
}
|
||||
|
||||
const response = {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
level: log.level,
|
||||
trigger: log.trigger,
|
||||
startedAt: log.startedAt.toISOString(),
|
||||
endedAt: log.endedAt?.toISOString() || null,
|
||||
totalDurationMs: log.totalDurationMs,
|
||||
files: log.files || undefined,
|
||||
workflow: workflowSummary,
|
||||
executionData: (await materializeExecutionData(
|
||||
log.executionData as Record<string, unknown> | null,
|
||||
{
|
||||
workspaceId: log.workspaceId,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
}
|
||||
)) as any,
|
||||
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
}
|
||||
|
||||
// Get user's workflow execution limits and usage
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
// Create response with limits information
|
||||
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
|
||||
|
||||
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Log details fetch error`, { error: error.message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1GetExecutionContract } from '@/lib/api/contracts/v1/logs'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import {
|
||||
checkRateLimit,
|
||||
createRateLimitResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1ExecutionAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => {
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'logs-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1GetExecutionContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid execution ID' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { executionId } = parsed.data.params
|
||||
|
||||
logger.debug(`Fetching execution data for: ${executionId}`)
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workflowExecutionLogs)
|
||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||
.limit(1)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workflowLog = rows[0]
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId)
|
||||
if (accessError) {
|
||||
return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [snapshot] = await db
|
||||
.select()
|
||||
.from(workflowExecutionSnapshots)
|
||||
.where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId))
|
||||
.limit(1)
|
||||
|
||||
if (!snapshot) {
|
||||
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const response = {
|
||||
executionId,
|
||||
workflowId: workflowLog.workflowId,
|
||||
workflowState: snapshot.stateData,
|
||||
executionMetadata: {
|
||||
trigger: workflowLog.trigger,
|
||||
startedAt: workflowLog.startedAt.toISOString(),
|
||||
endedAt: workflowLog.endedAt?.toISOString(),
|
||||
totalDurationMs: workflowLog.totalDurationMs,
|
||||
// Sourced from the cost_total projection of the usage_log ledger
|
||||
// (the deprecated cost jsonb column was dropped).
|
||||
cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null,
|
||||
},
|
||||
}
|
||||
|
||||
logger.debug(`Successfully fetched execution data for: ${executionId}`)
|
||||
logger.debug(
|
||||
`Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
|
||||
)
|
||||
|
||||
// Get user's workflow execution limits and usage
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
// Create response with limits information
|
||||
const apiResponse = createApiResponse(
|
||||
{
|
||||
...response,
|
||||
},
|
||||
limits,
|
||||
rateLimit
|
||||
)
|
||||
|
||||
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching execution data:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
|
||||
|
||||
export interface LogFilters {
|
||||
workspaceId: string
|
||||
workflowIds?: string[]
|
||||
folderIds?: string[]
|
||||
triggers?: string[]
|
||||
level?: 'info' | 'error'
|
||||
startDate?: Date
|
||||
endDate?: Date
|
||||
executionId?: string
|
||||
minDurationMs?: number
|
||||
maxDurationMs?: number
|
||||
minCost?: number
|
||||
maxCost?: number
|
||||
model?: string
|
||||
cursor?: {
|
||||
startedAt: string
|
||||
id: string
|
||||
}
|
||||
order?: 'desc' | 'asc'
|
||||
}
|
||||
|
||||
export function buildLogFilters(filters: LogFilters): SQL<unknown> {
|
||||
const conditions: SQL<unknown>[] = []
|
||||
|
||||
conditions.push(eq(workflowExecutionLogs.workspaceId, filters.workspaceId))
|
||||
|
||||
// Cursor-based pagination
|
||||
if (filters.cursor) {
|
||||
const cursorDate = new Date(filters.cursor.startedAt)
|
||||
if (filters.order === 'desc') {
|
||||
conditions.push(
|
||||
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) < (${cursorDate}, ${filters.cursor.id})`
|
||||
)
|
||||
} else {
|
||||
conditions.push(
|
||||
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) > (${cursorDate}, ${filters.cursor.id})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow IDs filter
|
||||
if (filters.workflowIds && filters.workflowIds.length > 0) {
|
||||
conditions.push(inArray(workflow.id, filters.workflowIds))
|
||||
}
|
||||
|
||||
// Folder IDs filter
|
||||
if (filters.folderIds && filters.folderIds.length > 0) {
|
||||
conditions.push(inArray(workflow.folderId, filters.folderIds))
|
||||
}
|
||||
|
||||
// Triggers filter
|
||||
if (filters.triggers && filters.triggers.length > 0 && !filters.triggers.includes('all')) {
|
||||
conditions.push(inArray(workflowExecutionLogs.trigger, filters.triggers))
|
||||
}
|
||||
|
||||
// Level filter
|
||||
if (filters.level) {
|
||||
conditions.push(eq(workflowExecutionLogs.level, filters.level))
|
||||
}
|
||||
|
||||
// Date range filters
|
||||
if (filters.startDate) {
|
||||
conditions.push(gte(workflowExecutionLogs.startedAt, filters.startDate))
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
conditions.push(lte(workflowExecutionLogs.startedAt, filters.endDate))
|
||||
}
|
||||
|
||||
// Search filter (execution ID)
|
||||
if (filters.executionId) {
|
||||
conditions.push(eq(workflowExecutionLogs.executionId, filters.executionId))
|
||||
}
|
||||
|
||||
// Duration filters
|
||||
if (filters.minDurationMs !== undefined) {
|
||||
conditions.push(gte(workflowExecutionLogs.totalDurationMs, filters.minDurationMs))
|
||||
}
|
||||
|
||||
if (filters.maxDurationMs !== undefined) {
|
||||
conditions.push(lte(workflowExecutionLogs.totalDurationMs, filters.maxDurationMs))
|
||||
}
|
||||
|
||||
// Cost filters — indexed projection of the usage_log ledger (dollars).
|
||||
if (filters.minCost !== undefined) {
|
||||
conditions.push(sql`${workflowExecutionLogs.costTotal} >= ${filters.minCost}`)
|
||||
}
|
||||
|
||||
if (filters.maxCost !== undefined) {
|
||||
conditions.push(sql`${workflowExecutionLogs.costTotal} <= ${filters.maxCost}`)
|
||||
}
|
||||
|
||||
// Model filter — uses the models_used projection (includes zero-cost/BYOK
|
||||
// models, which the usage_log ledger drops), preserving prior behavior.
|
||||
if (filters.model) {
|
||||
conditions.push(sql`${workflowExecutionLogs.modelsUsed} @> ARRAY[${filters.model}]::text[]`)
|
||||
}
|
||||
|
||||
// Combine all conditions with AND
|
||||
return conditions.length > 0 ? and(...conditions)! : sql`true`
|
||||
}
|
||||
|
||||
export function getOrderBy(order: 'desc' | 'asc' = 'desc') {
|
||||
return order === 'desc'
|
||||
? desc(workflowExecutionLogs.startedAt)
|
||||
: sql`${workflowExecutionLogs.startedAt} ASC`
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
|
||||
export interface UserLimits {
|
||||
workflowExecutionRateLimit: {
|
||||
sync: {
|
||||
requestsPerMinute: number
|
||||
maxBurst: number
|
||||
remaining: number
|
||||
resetAt: string
|
||||
}
|
||||
async: {
|
||||
requestsPerMinute: number
|
||||
maxBurst: number
|
||||
remaining: number
|
||||
resetAt: string
|
||||
}
|
||||
}
|
||||
usage: {
|
||||
currentPeriodCost: number
|
||||
limit: number
|
||||
plan: string
|
||||
isExceeded: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserLimits(userId: string): Promise<UserLimits> {
|
||||
const [userSubscription, usageCheck, effectiveCost, rateLimiter] = await Promise.all([
|
||||
getHighestPrioritySubscription(userId),
|
||||
checkServerSideUsageLimits(userId),
|
||||
getEffectiveCurrentPeriodCost(userId),
|
||||
Promise.resolve(new RateLimiter()),
|
||||
])
|
||||
|
||||
const [syncStatus, asyncStatus] = await Promise.all([
|
||||
rateLimiter.getRateLimitStatusWithSubscription(userId, userSubscription, 'api', false),
|
||||
rateLimiter.getRateLimitStatusWithSubscription(userId, userSubscription, 'api', true),
|
||||
])
|
||||
|
||||
return {
|
||||
workflowExecutionRateLimit: {
|
||||
sync: {
|
||||
requestsPerMinute: syncStatus.requestsPerMinute,
|
||||
maxBurst: syncStatus.maxBurst,
|
||||
remaining: syncStatus.remaining,
|
||||
resetAt: syncStatus.resetAt.toISOString(),
|
||||
},
|
||||
async: {
|
||||
requestsPerMinute: asyncStatus.requestsPerMinute,
|
||||
maxBurst: asyncStatus.maxBurst,
|
||||
remaining: asyncStatus.remaining,
|
||||
resetAt: asyncStatus.resetAt.toISOString(),
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
currentPeriodCost: effectiveCost,
|
||||
limit: usageCheck.limit,
|
||||
plan: userSubscription?.plan || 'free',
|
||||
isExceeded: usageCheck.isExceeded,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiResponse<T>(
|
||||
data: T,
|
||||
limits: UserLimits,
|
||||
apiRateLimit: { limit: number; remaining: number; resetAt: Date }
|
||||
) {
|
||||
return {
|
||||
body: {
|
||||
...data,
|
||||
limits,
|
||||
},
|
||||
headers: {
|
||||
'X-RateLimit-Limit': apiRateLimit.limit.toString(),
|
||||
'X-RateLimit-Remaining': apiRateLimit.remaining.toString(),
|
||||
'X-RateLimit-Reset': apiRateLimit.resetAt.toISOString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
|
||||
import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import {
|
||||
checkRateLimit,
|
||||
createRateLimitResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1LogsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface CursorData {
|
||||
startedAt: string
|
||||
id: string
|
||||
}
|
||||
|
||||
function encodeCursor(data: CursorData): string {
|
||||
return Buffer.from(JSON.stringify(data)).toString('base64')
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): CursorData | null {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(cursor, 'base64').toString())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'logs')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(
|
||||
v1ListLogsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error: getValidationErrorMessage(error, 'Invalid parameters'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const params = parsed.data.query
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read')
|
||||
if (accessError) return accessError
|
||||
|
||||
logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, {
|
||||
userId,
|
||||
filters: {
|
||||
workflowIds: params.workflowIds,
|
||||
triggers: params.triggers,
|
||||
level: params.level,
|
||||
},
|
||||
})
|
||||
|
||||
const filters = {
|
||||
workspaceId: params.workspaceId,
|
||||
workflowIds: params.workflowIds?.split(',').filter(Boolean),
|
||||
folderIds: params.folderIds?.split(',').filter(Boolean),
|
||||
triggers: params.triggers?.split(',').filter(Boolean),
|
||||
level: params.level,
|
||||
startDate: params.startDate ? new Date(params.startDate) : undefined,
|
||||
endDate: params.endDate ? new Date(params.endDate) : undefined,
|
||||
executionId: params.executionId,
|
||||
minDurationMs: params.minDurationMs,
|
||||
maxDurationMs: params.maxDurationMs,
|
||||
minCost: params.minCost,
|
||||
maxCost: params.maxCost,
|
||||
model: params.model,
|
||||
cursor: params.cursor ? decodeCursor(params.cursor) || undefined : undefined,
|
||||
order: params.order,
|
||||
}
|
||||
|
||||
const conditions = buildLogFilters(filters)
|
||||
const orderBy = getOrderBy(params.order)
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
workspaceId: workflowExecutionLogs.workspaceId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
deploymentVersionId: workflowExecutionLogs.deploymentVersionId,
|
||||
level: workflowExecutionLogs.level,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
costTotal: workflowExecutionLogs.costTotal,
|
||||
files: workflowExecutionLogs.files,
|
||||
executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
|
||||
const logs = await baseQuery
|
||||
.where(conditions)
|
||||
.orderBy(orderBy)
|
||||
.limit(params.limit + 1)
|
||||
|
||||
const hasMore = logs.length > params.limit
|
||||
const data = logs.slice(0, params.limit)
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (hasMore && data.length > 0) {
|
||||
const lastLog = data[data.length - 1]
|
||||
nextCursor = encodeCursor({
|
||||
startedAt: lastLog.startedAt.toISOString(),
|
||||
id: lastLog.id,
|
||||
})
|
||||
}
|
||||
|
||||
const needsMaterialize =
|
||||
params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans)
|
||||
|
||||
const buildBase = (log: (typeof data)[number]) => {
|
||||
const result: any = {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
deploymentVersionId: log.deploymentVersionId,
|
||||
level: log.level,
|
||||
trigger: log.trigger,
|
||||
startedAt: log.startedAt.toISOString(),
|
||||
endedAt: log.endedAt?.toISOString() || null,
|
||||
totalDurationMs: log.totalDurationMs,
|
||||
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
|
||||
files: log.files || null,
|
||||
}
|
||||
|
||||
if (params.details === 'full') {
|
||||
result.workflow = {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName || 'Deleted Workflow',
|
||||
description: log.workflowDescription,
|
||||
deleted: !log.workflowName,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const formattedLogs = needsMaterialize
|
||||
? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => {
|
||||
const result = buildBase(log)
|
||||
if (log.executionData) {
|
||||
const execData = (await materializeExecutionData(
|
||||
log.executionData as Record<string, unknown> | null,
|
||||
{
|
||||
workspaceId: log.workspaceId,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
}
|
||||
)) as any
|
||||
if (params.includeFinalOutput && execData.finalOutput) {
|
||||
result.finalOutput = execData.finalOutput
|
||||
}
|
||||
if (params.includeTraceSpans && execData.traceSpans) {
|
||||
result.traceSpans = execData.traceSpans
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
: data.map(buildBase)
|
||||
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
const response = createApiResponse(
|
||||
{
|
||||
data: formattedLogs,
|
||||
nextCursor,
|
||||
},
|
||||
limits,
|
||||
rateLimit // This is the API endpoint rate limit, not workflow execution limits
|
||||
)
|
||||
|
||||
return NextResponse.json(response.body, { headers: response.headers })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Logs fetch error`, { error: error.message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user