Skip to main content

Cloudflare Worker Logs Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Receive Cloudflare Worker error logs via OTLP HTTP push, store them in PostgreSQL, and display them in a new “Worker Logs” tab in the superadmin dashboard. Architecture: Cloudflare pushes OTLP JSON logs to a new public endpoint POST /api/v1/otlp/logs; the endpoint validates a bearer token, filters records to severityNumber ≥ 17 (ERROR/FATAL), and bulk-inserts them into a worker_logs table. A new superadmin GET /admin/worker-logs endpoint serves paginated results to a new WorkerLogsPage tab. A daily cron step purges records older than 90 days. Tech Stack: Hono, Drizzle ORM, PostgreSQL, Vitest, React, shadcn/ui, Lucide icons

File Map

FileActionResponsibility
server/src/schema/worker_logs.tsCreateDrizzle table definition
server/src/schema/index.tsModifyExport new table
server/src/lib/schema-ensure.tsModifyRuntime CREATE TABLE IF NOT EXISTS
server/src/lib/otlp-parser.tsCreatePure OTLP JSON → DB row transformer
server/src/lib/otlp-parser.test.tsCreateVitest unit tests for parser
server/src/routes/otlp.tsCreatePublic OTLP ingest route
server/src/api.tsModifyRegister /otlp route
server/src/routes/admin.tsModifyAdd GET /worker-logs endpoint
server/src/scheduled.tsModifyAdd 90-day TTL cleanup step
ui/src/lib/serverComm.tsModifyAdd WorkerLog type + getWorkerLogs
ui/src/components/superadmin/WorkerLogsPage.tsxCreateSuperadmin tab UI
ui/src/hooks/useNavItems.tsModifyAdd “Worker Logs” nav item
ui/src/components/dashboards/SuperAdminDashboard.tsxModifyRender WorkerLogsPage for new view

Task 1: Drizzle Schema

Files:
  • Create: server/src/schema/worker_logs.ts
  • Modify: server/src/schema/index.ts
  • Step 1: Create the schema file
// server/src/schema/worker_logs.ts
import { pgTable, text, timestamp, integer, jsonb, index } from 'drizzle-orm/pg-core';
import { appSchema } from './base';

export const workerLogs = appSchema.table('worker_logs', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  timestamp: timestamp('timestamp').notNull(),
  severity: text('severity').notNull(),       // 'ERROR' | 'FATAL'
  severityNumber: integer('severity_number').notNull(),
  message: text('message').notNull(),
  traceId: text('trace_id'),
  resourceAttrs: jsonb('resource_attrs'),     // worker name, env, version
  logAttrs: jsonb('log_attrs'),               // url, method, status code etc.
  receivedAt: timestamp('received_at').defaultNow().notNull(),
}, (table) => ({
  timestampIdx: index('worker_logs_timestamp_idx').on(table.timestamp),
  receivedAtIdx: index('worker_logs_received_at_idx').on(table.receivedAt),
}));

export type WorkerLog = typeof workerLogs.$inferSelect;
export type NewWorkerLog = typeof workerLogs.$inferInsert;
  • Step 2: Export from schema index
In server/src/schema/index.ts, add after the last export line:
export * from './worker_logs';
  • Step 3: Commit
git add server/src/schema/worker_logs.ts server/src/schema/index.ts
git commit -m "feat(schema): add worker_logs table for Cloudflare OTLP error logs"

Task 2: Runtime Schema-Ensure

Files:
  • Modify: server/src/lib/schema-ensure.ts
  • Step 1: Add flag variable
Open server/src/lib/schema-ensure.ts. After the last let ...Ensured = false; flag variable near the top of the file, add:
let workerLogsEnsured = false;
  • Step 2: Add ensureWorkerLogsSchema function
Append at the end of server/src/lib/schema-ensure.ts:
export async function ensureWorkerLogsSchema(db: any): Promise<void> {
  if (workerLogsEnsured) return;

  try {
    await db.execute(sql`
      CREATE TABLE IF NOT EXISTS app.worker_logs (
        id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
        timestamp TIMESTAMP NOT NULL,
        severity TEXT NOT NULL,
        severity_number INTEGER NOT NULL,
        message TEXT NOT NULL,
        trace_id TEXT,
        resource_attrs JSONB,
        log_attrs JSONB,
        received_at TIMESTAMP DEFAULT NOW() NOT NULL
      );
    `);
    await db.execute(sql`CREATE INDEX IF NOT EXISTS worker_logs_timestamp_idx ON app.worker_logs (timestamp DESC);`);
    await db.execute(sql`CREATE INDEX IF NOT EXISTS worker_logs_received_at_idx ON app.worker_logs (received_at DESC);`);
    workerLogsEnsured = true;
  } catch (error) {
    console.error('Failed to ensure worker_logs schema', error);
    throw error;
  }
}
  • Step 3: Commit
git add server/src/lib/schema-ensure.ts
git commit -m "feat(schema): add ensureWorkerLogsSchema runtime migration"

Task 3: OTLP Parser (Pure Function + Tests)

Files:
  • Create: server/src/lib/otlp-parser.ts
  • Create: server/src/lib/otlp-parser.test.ts
  • Step 1: Write the failing tests first
// server/src/lib/otlp-parser.test.ts
import { describe, it, expect } from 'vitest';
import { parseOtlpLogs } from './otlp-parser';

const makeRecord = (overrides: Record<string, any> = {}) => ({
  timeUnixNano: '1714000000000000000',
  severityNumber: 17,
  severityText: 'ERROR',
  body: { stringValue: 'Something broke' },
  traceId: 'abc123',
  attributes: [
    { key: 'http.method', value: { stringValue: 'POST' } },
    { key: 'http.url', value: { stringValue: '/api/v1/patients' } },
  ],
  ...overrides,
});

const makePayload = (records: any[]) => ({
  resourceLogs: [
    {
      resource: {
        attributes: [
          { key: 'service.name', value: { stringValue: 'odonto-prod' } },
          { key: 'deployment.environment', value: { stringValue: 'production' } },
        ],
      },
      scopeLogs: [{ logRecords: records }],
    },
  ],
});

describe('parseOtlpLogs', () => {
  it('returns empty array for empty payload', () => {
    expect(parseOtlpLogs({})).toEqual([]);
    expect(parseOtlpLogs({ resourceLogs: [] })).toEqual([]);
  });

  it('filters out records below severityNumber 17', () => {
    const payload = makePayload([
      makeRecord({ severityNumber: 9, severityText: 'INFO' }),   // dropped
      makeRecord({ severityNumber: 13, severityText: 'WARN' }),  // dropped
      makeRecord({ severityNumber: 16, severityText: 'WARN' }),  // dropped
    ]);
    expect(parseOtlpLogs(payload)).toHaveLength(0);
  });

  it('keeps ERROR (17) and FATAL (21) records', () => {
    const payload = makePayload([
      makeRecord({ severityNumber: 17, severityText: 'ERROR' }),
      makeRecord({ severityNumber: 21, severityText: 'FATAL' }),
    ]);
    const result = parseOtlpLogs(payload);
    expect(result).toHaveLength(2);
    expect(result[0].severity).toBe('ERROR');
    expect(result[1].severity).toBe('FATAL');
  });

  it('maps fields correctly', () => {
    const payload = makePayload([makeRecord()]);
    const [row] = parseOtlpLogs(payload);

    expect(row.message).toBe('Something broke');
    expect(row.severityNumber).toBe(17);
    expect(row.traceId).toBe('abc123');
    expect(row.timestamp).toBeInstanceOf(Date);
    expect((row.resourceAttrs as any)['service.name']).toBe('odonto-prod');
    expect((row.logAttrs as any)['http.method']).toBe('POST');
  });

  it('handles missing traceId gracefully', () => {
    const record = makeRecord();
    delete record.traceId;
    const payload = makePayload([record]);
    const [row] = parseOtlpLogs(payload);
    expect(row.traceId).toBeNull();
  });

  it('handles missing body gracefully', () => {
    const record = makeRecord();
    delete (record as any).body;
    const payload = makePayload([record]);
    const [row] = parseOtlpLogs(payload);
    expect(row.message).toBe('');
  });
});
  • Step 2: Run tests to confirm they fail
cd server && npx vitest run src/lib/otlp-parser.test.ts
Expected: FAIL with Cannot find module './otlp-parser'
  • Step 3: Implement the parser
// server/src/lib/otlp-parser.ts
import type { NewWorkerLog } from '../schema/worker_logs';

type OtlpAttributeValue =
  | { stringValue: string }
  | { intValue: string | number }
  | { boolValue: boolean }
  | { doubleValue: number };

interface OtlpAttribute {
  key: string;
  value: OtlpAttributeValue;
}

function resolveValue(v: OtlpAttributeValue): string | number | boolean {
  if ('stringValue' in v) return v.stringValue;
  if ('intValue' in v) return Number(v.intValue);
  if ('boolValue' in v) return v.boolValue;
  if ('doubleValue' in v) return v.doubleValue;
  return '';
}

function attrsToRecord(attrs: OtlpAttribute[] = []): Record<string, unknown> {
  return Object.fromEntries(attrs.map((a) => [a.key, resolveValue(a.value)]));
}

export function parseOtlpLogs(payload: any): Omit<NewWorkerLog, 'id' | 'receivedAt'>[] {
  const resourceLogs: any[] = payload?.resourceLogs ?? [];
  const rows: Omit<NewWorkerLog, 'id' | 'receivedAt'>[] = [];

  for (const rl of resourceLogs) {
    const resourceAttrs = attrsToRecord(rl?.resource?.attributes);
    const scopeLogs: any[] = rl?.scopeLogs ?? [];

    for (const sl of scopeLogs) {
      const logRecords: any[] = sl?.logRecords ?? [];

      for (const lr of logRecords) {
        const severityNumber: number = lr?.severityNumber ?? 0;
        if (severityNumber < 17) continue;

        const nanoStr: string = lr?.timeUnixNano ?? '0';
        const timestampMs = Number(BigInt(nanoStr) / 1_000_000n);

        rows.push({
          timestamp: new Date(timestampMs),
          severity: (lr?.severityText ?? 'ERROR').toUpperCase(),
          severityNumber,
          message: lr?.body?.stringValue ?? '',
          traceId: lr?.traceId ?? null,
          resourceAttrs,
          logAttrs: attrsToRecord(lr?.attributes),
        });
      }
    }
  }

  return rows;
}
  • Step 4: Run tests to confirm they pass
cd server && npx vitest run src/lib/otlp-parser.test.ts
Expected: All 6 tests PASS
  • Step 5: Commit
git add server/src/lib/otlp-parser.ts server/src/lib/otlp-parser.test.ts
git commit -m "feat(lib): add OTLP log parser with unit tests"

Task 4: OTLP Ingest Route

Files:
  • Create: server/src/routes/otlp.ts
  • Modify: server/src/api.ts
  • Step 1: Create the route file
// server/src/routes/otlp.ts
import { Hono } from 'hono';
import { getDatabase } from '../lib/db';
import { getDatabaseUrl, getEnv } from '../lib/env';
import { workerLogs } from '../schema/worker_logs';
import { ensureWorkerLogsSchema } from '../lib/schema-ensure';
import { parseOtlpLogs } from '../lib/otlp-parser';

const otlpRoute = new Hono();

otlpRoute.post('/logs', async (c) => {
  try {
    const env = getEnv();
    const secret = env.OTLP_INGEST_SECRET;

    const authHeader = c.req.header('Authorization') ?? '';
    const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';

    if (!secret || token !== secret) {
      return c.json({ error: 'Unauthorized' }, 401);
    }

    let payload: unknown;
    try {
      payload = await c.req.json();
    } catch {
      return c.json({}, 200);
    }

    const rows = parseOtlpLogs(payload);

    if (rows.length > 0) {
      const db = await getDatabase(getDatabaseUrl());
      await ensureWorkerLogsSchema(db);
      await db.insert(workerLogs).values(rows);
    }

    return c.json({}, 200);
  } catch (error) {
    console.error('[OTLP] Ingest error:', error);
    return c.json({}, 200);
  }
});

export default otlpRoute;
  • Step 2: Register the route in api.ts
In server/src/api.ts, find the WhatsApp webhook registration:
// WhatsApp Business API webhook (public - Meta needs direct access)
import { whatsappWebhookRoute } from './routes/whatsapp-webhook';
api.route('/whatsapp', whatsappWebhookRoute);
Add immediately after it:
// Cloudflare OTLP log ingest (public - authenticated via bearer token, not JWT)
import otlpRoute from './routes/otlp';
api.route('/otlp', otlpRoute);
  • Step 3: Add OTLP_INGEST_SECRET to wrangler.toml vars block (non-secret placeholder)
Open server/wrangler.toml. In the [vars] block, add a comment-only note (the actual value goes in Cloudflare Secrets, not the toml):
# OTLP_INGEST_SECRET — set via: wrangler secret put OTLP_INGEST_SECRET
Add the same comment to the [env.production.vars] block.
  • Step 4: Verify TypeScript compiles
cd server && npx tsc --noEmit
Expected: no errors
  • Step 5: Commit
git add server/src/routes/otlp.ts server/src/api.ts server/wrangler.toml
git commit -m "feat(routes): add public OTLP log ingest endpoint"

Task 5: Superadmin GET Endpoint

Files:
  • Modify: server/src/routes/admin.ts
  • Step 1: Add import at the top of admin.ts
Find the existing imports block in server/src/routes/admin.ts. Add these imports alongside the existing ones:
import { workerLogs } from '../schema/worker_logs';
import { ensureWorkerLogsSchema } from '../lib/schema-ensure';
import { ilike, gte, lte, count } from 'drizzle-orm';
Note: desc, eq, and are already imported — do not duplicate them.
  • Step 2: Add the GET /worker-logs handler
Append at the end of server/src/routes/admin.ts, before the final export default adminRoute line:
adminRoute.get('/worker-logs', async (c) => {
  try {
    const user = c.get('user') as any;
    if (user.role !== 'superadmin') {
      throw new AppError('Unauthorized', 403);
    }

    const db = await getDatabase(getDatabaseUrl());
    await ensureWorkerLogsSchema(db);

    const pageParam = parseInt(c.req.query('page') ?? '1', 10);
    const limitParam = parseInt(c.req.query('limit') ?? '50', 10);
    const page = isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
    const limit = isNaN(limitParam) || limitParam < 1 ? 50 : Math.min(limitParam, 200);
    const offset = (page - 1) * limit;

    const startDate = c.req.query('startDate');
    const endDate = c.req.query('endDate');
    const severity = c.req.query('severity');
    const search = c.req.query('search');

    const conditions: any[] = [];
    if (startDate) conditions.push(gte(workerLogs.timestamp, new Date(startDate)));
    if (endDate) conditions.push(lte(workerLogs.timestamp, new Date(endDate)));
    if (severity && severity !== 'all') conditions.push(eq(workerLogs.severity, severity.toUpperCase()));
    if (search) conditions.push(ilike(workerLogs.message, `%${search}%`));

    const where = conditions.length > 0 ? and(...conditions) : undefined;

    const [logs, [{ total }]] = await Promise.all([
      db.select().from(workerLogs)
        .where(where)
        .orderBy(desc(workerLogs.timestamp))
        .limit(limit)
        .offset(offset),
      db.select({ total: count() }).from(workerLogs).where(where),
    ]);

    return c.json({
      logs,
      total: Number(total),
      page,
      totalPages: Math.ceil(Number(total) / limit),
    });
  } catch (error) {
    return handleError(error, c);
  }
});
  • Step 3: Verify TypeScript compiles
cd server && npx tsc --noEmit
Expected: no errors
  • Step 4: Commit
git add server/src/routes/admin.ts
git commit -m "feat(admin): add GET /worker-logs endpoint for superadmin"

Task 6: TTL Cron Cleanup

Files:
  • Modify: server/src/scheduled.ts
  • Step 1: Add imports
At the top of server/src/scheduled.ts, add to the existing imports:
import { workerLogs } from './schema/worker_logs';
import { lt } from 'drizzle-orm';
Note: sql, and are likely already imported — check before adding.
  • Step 2: Add cleanup step inside handleScheduled
Find the comment // 5. OTHER SCHEDULED TASKS in scheduled.ts and add before it:
// 5. PURGE WORKER LOGS OLDER THAN 90 DAYS
try {
  const ninetyDaysAgo = new Date(now);
  ninetyDaysAgo.setDate(now.getDate() - 90);
  const deleted = await db.delete(workerLogs)
    .where(lt(workerLogs.receivedAt, ninetyDaysAgo))
    .returning({ id: workerLogs.id });
  if (deleted.length > 0) {
    console.log(`🗑️ Purged ${deleted.length} worker log entries older than 90 days.`);
  }
} catch (err) {
  console.error('Failed to purge old worker logs:', err);
}
  • Step 3: Verify TypeScript compiles
cd server && npx tsc --noEmit
Expected: no errors
  • Step 4: Commit
git add server/src/scheduled.ts
git commit -m "feat(cron): purge worker_logs older than 90 days in daily cron"

Task 7: Frontend API Helper

Files:
  • Modify: ui/src/lib/serverComm.ts
  • Step 1: Add the WorkerLog type and getWorkerLogs function
In ui/src/lib/serverComm.ts, find the // ==================== License Management ==================== section boundary (near line 4478). Add the following block just before it:
// ==================== Worker Logs ====================

export interface WorkerLog {
  id: string;
  timestamp: string;
  severity: string;
  severityNumber: number;
  message: string;
  traceId: string | null;
  resourceAttrs: Record<string, unknown> | null;
  logAttrs: Record<string, unknown> | null;
  receivedAt: string;
}

export interface WorkerLogsResponse {
  logs: WorkerLog[];
  total: number;
  page: number;
  totalPages: number;
}

export async function getWorkerLogs(params?: {
  page?: number;
  limit?: number;
  startDate?: string;
  endDate?: string;
  severity?: string;
  search?: string;
}): Promise<WorkerLogsResponse> {
  const queryParams = new URLSearchParams();
  if (params?.page) queryParams.append('page', params.page.toString());
  if (params?.limit) queryParams.append('limit', params.limit.toString());
  if (params?.startDate) queryParams.append('startDate', params.startDate);
  if (params?.endDate) queryParams.append('endDate', params.endDate);
  if (params?.severity) queryParams.append('severity', params.severity);
  if (params?.search) queryParams.append('search', params.search);

  const queryString = queryParams.toString();
  const url = `/api/v1/protected/admin/worker-logs${queryString ? `?${queryString}` : ''}`;
  const response = await fetchWithAuth(url);
  return response.json();
}
Also add getWorkerLogs to the export list at the bottom of the file (find the object that includes getAuditLogs and add alongside it):
  getWorkerLogs,
  • Step 2: Verify TypeScript compiles
cd ui && npx tsc --noEmit
Expected: no errors
  • Step 3: Commit
git add ui/src/lib/serverComm.ts
git commit -m "feat(serverComm): add getWorkerLogs API helper"

Task 8: WorkerLogsPage Component

Files:
  • Create: ui/src/components/superadmin/WorkerLogsPage.tsx
  • Step 1: Create the component
// ui/src/components/superadmin/WorkerLogsPage.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Badge } from '../ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
import { ScrollArea } from '../ui/scroll-area';
import { DatePicker } from '../ui/date-picker';
import {
  Search,
  RefreshCw,
  Server,
  AlertCircle,
  ChevronLeft,
  ChevronRight,
  Eye,
  Loader2,
  ExternalLink,
} from 'lucide-react';
import { getWorkerLogs, type WorkerLog } from '@/lib/serverComm';
import { toast } from '@/lib/toast';
import { formatDateTimePKT } from '@/lib/datetime';
import { format } from 'date-fns';

export default function WorkerLogsPage() {
  const [logs, setLogs] = useState<WorkerLog[]>([]);
  const [loading, setLoading] = useState(true);
  const [total, setTotal] = useState(0);
  const [totalPages, setTotalPages] = useState(1);
  const [currentPage, setCurrentPage] = useState(1);
  const [selectedLog, setSelectedLog] = useState<WorkerLog | null>(null);
  const [dialogOpen, setDialogOpen] = useState(false);

  const [startDate, setStartDate] = useState('');
  const [endDate, setEndDate] = useState('');
  const [severityFilter, setSeverityFilter] = useState('all');
  const [searchQuery, setSearchQuery] = useState('');

  const limit = 50;

  const fetchLogs = useCallback(async (page = currentPage) => {
    try {
      setLoading(true);
      const result = await getWorkerLogs({
        page,
        limit,
        startDate: startDate || undefined,
        endDate: endDate || undefined,
        severity: severityFilter !== 'all' ? severityFilter : undefined,
        search: searchQuery || undefined,
      });
      setLogs(result.logs);
      setTotal(result.total);
      setTotalPages(result.totalPages);
    } catch (error: any) {
      toast.error(error.message || 'Failed to fetch worker logs');
      setLogs([]);
    } finally {
      setLoading(false);
    }
  }, [currentPage, startDate, endDate, severityFilter, searchQuery]);

  useEffect(() => {
    fetchLogs(currentPage);
  }, [currentPage]);

  const handleFilter = () => {
    setCurrentPage(1);
    fetchLogs(1);
  };

  const handleReset = () => {
    setStartDate('');
    setEndDate('');
    setSeverityFilter('all');
    setSearchQuery('');
    setCurrentPage(1);
    setTimeout(() => fetchLogs(1), 0);
  };

  const getSeverityBadge = (severity: string) => {
    if (severity === 'FATAL') {
      return <Badge className="bg-red-900 text-white text-xs">FATAL</Badge>;
    }
    return <Badge className="bg-red-600 text-white text-xs">ERROR</Badge>;
  };

  const getWorkerName = (log: WorkerLog): string => {
    const attrs = log.resourceAttrs as Record<string, unknown> | null;
    return (attrs?.['service.name'] as string) ?? (attrs?.['faas.name'] as string) ?? '—';
  };

  const openDetail = (log: WorkerLog) => {
    setSelectedLog(log);
    setDialogOpen(true);
  };

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-2">
          <Server className="h-5 w-5 text-muted-foreground" />
          <h2 className="text-xl font-semibold">Worker Logs</h2>
          <Badge variant="outline" className="ml-1">{total} errors stored</Badge>
        </div>
        <div className="flex gap-2">
          <Button
            variant="outline"
            size="sm"
            onClick={() => fetchLogs(currentPage)}
            disabled={loading}
          >
            <RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
            Refresh
          </Button>
          <a
            href="https://dash.cloudflare.com"
            target="_blank"
            rel="noopener noreferrer"
          >
            <Button variant="outline" size="sm">
              <ExternalLink className="h-4 w-4 mr-1" />
              Cloudflare Dashboard
            </Button>
          </a>
        </div>
      </div>

      {/* Filters */}
      <Card>
        <CardContent className="pt-4">
          <div className="flex flex-wrap gap-3 items-end">
            <div className="flex flex-col gap-1">
              <span className="text-xs text-muted-foreground">From</span>
              <DatePicker
                value={startDate}
                onChange={(date) => setStartDate(date ? format(date, 'yyyy-MM-dd') : '')}
                placeholder="Start date"
              />
            </div>
            <div className="flex flex-col gap-1">
              <span className="text-xs text-muted-foreground">To</span>
              <DatePicker
                value={endDate}
                onChange={(date) => setEndDate(date ? format(date, 'yyyy-MM-dd') : '')}
                placeholder="End date"
              />
            </div>
            <div className="flex flex-col gap-1">
              <span className="text-xs text-muted-foreground">Severity</span>
              <Select value={severityFilter} onValueChange={setSeverityFilter}>
                <SelectTrigger className="w-32">
                  <SelectValue placeholder="All" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="all">All</SelectItem>
                  <SelectItem value="ERROR">ERROR</SelectItem>
                  <SelectItem value="FATAL">FATAL</SelectItem>
                </SelectContent>
              </Select>
            </div>
            <div className="flex flex-col gap-1 flex-1 min-w-48">
              <span className="text-xs text-muted-foreground">Search message</span>
              <div className="relative">
                <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
                <Input
                  className="pl-8"
                  placeholder="Search error messages..."
                  value={searchQuery}
                  onChange={(e) => setSearchQuery(e.target.value)}
                  onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
                />
              </div>
            </div>
            <Button onClick={handleFilter} disabled={loading}>Filter</Button>
            <Button variant="outline" onClick={handleReset} disabled={loading}>Reset</Button>
          </div>
        </CardContent>
      </Card>

      {/* Table */}
      <Card>
        <CardHeader>
          <CardTitle className="flex items-center gap-2 text-base">
            <AlertCircle className="h-4 w-4 text-red-500" />
            Error Logs
            <span className="text-sm font-normal text-muted-foreground ml-1">
              (stored permanently — INFO/DEBUG/WARN available in Cloudflare Dashboard)
            </span>
          </CardTitle>
        </CardHeader>
        <CardContent>
          {loading ? (
            <div className="flex items-center justify-center py-12">
              <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
            </div>
          ) : logs.length === 0 ? (
            <div className="text-center py-12 text-muted-foreground">
              No error logs found for the selected filters.
            </div>
          ) : (
            <>
              <Table>
                <TableHeader>
                  <TableRow>
                    <TableHead className="w-44">Timestamp</TableHead>
                    <TableHead className="w-24">Severity</TableHead>
                    <TableHead className="w-32">Worker</TableHead>
                    <TableHead>Message</TableHead>
                    <TableHead className="w-10"></TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {logs.map((log) => (
                    <TableRow key={log.id} className="cursor-pointer hover:bg-muted/50">
                      <TableCell className="text-xs text-muted-foreground whitespace-nowrap">
                        {formatDateTimePKT(log.timestamp)}
                      </TableCell>
                      <TableCell>{getSeverityBadge(log.severity)}</TableCell>
                      <TableCell className="text-xs font-mono">{getWorkerName(log)}</TableCell>
                      <TableCell
                        className="text-sm max-w-md truncate"
                        title={log.message}
                      >
                        {log.message || '(no message)'}
                      </TableCell>
                      <TableCell>
                        <Button
                          variant="ghost"
                          size="icon"
                          className="h-7 w-7"
                          onClick={() => openDetail(log)}
                        >
                          <Eye className="h-4 w-4" />
                        </Button>
                      </TableCell>
                    </TableRow>
                  ))}
                </TableBody>
              </Table>

              {/* Pagination */}
              <div className="flex items-center justify-between mt-4 text-sm text-muted-foreground">
                <span>
                  Showing {(currentPage - 1) * limit + 1}
                  {Math.min(currentPage * limit, total)} of {total}
                </span>
                <div className="flex gap-1">
                  <Button
                    variant="outline"
                    size="icon"
                    className="h-8 w-8"
                    disabled={currentPage <= 1}
                    onClick={() => setCurrentPage((p) => p - 1)}
                  >
                    <ChevronLeft className="h-4 w-4" />
                  </Button>
                  <span className="px-3 py-1 border rounded text-xs">
                    {currentPage} / {totalPages}
                  </span>
                  <Button
                    variant="outline"
                    size="icon"
                    className="h-8 w-8"
                    disabled={currentPage >= totalPages}
                    onClick={() => setCurrentPage((p) => p + 1)}
                  >
                    <ChevronRight className="h-4 w-4" />
                  </Button>
                </div>
              </div>
            </>
          )}
        </CardContent>
      </Card>

      {/* Detail Dialog */}
      <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
        <DialogContent className="max-w-2xl">
          <DialogHeader>
            <DialogTitle className="flex items-center gap-2">
              {selectedLog && getSeverityBadge(selectedLog.severity)}
              <span className="text-sm font-mono truncate max-w-sm">
                {selectedLog?.message}
              </span>
            </DialogTitle>
          </DialogHeader>
          {selectedLog && (
            <ScrollArea className="max-h-[60vh]">
              <div className="space-y-4 pr-4">
                <div className="grid grid-cols-2 gap-2 text-sm">
                  <div>
                    <span className="text-muted-foreground">Timestamp</span>
                    <p className="font-mono">{formatDateTimePKT(selectedLog.timestamp)}</p>
                  </div>
                  <div>
                    <span className="text-muted-foreground">Trace ID</span>
                    <p className="font-mono text-xs">{selectedLog.traceId ?? '—'}</p>
                  </div>
                </div>
                {selectedLog.resourceAttrs && (
                  <div>
                    <p className="text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">Resource Attributes</p>
                    <pre className="bg-muted rounded p-3 text-xs overflow-auto">
                      {JSON.stringify(selectedLog.resourceAttrs, null, 2)}
                    </pre>
                  </div>
                )}
                {selectedLog.logAttrs && (
                  <div>
                    <p className="text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">Log Attributes</p>
                    <pre className="bg-muted rounded p-3 text-xs overflow-auto">
                      {JSON.stringify(selectedLog.logAttrs, null, 2)}
                    </pre>
                  </div>
                )}
              </div>
            </ScrollArea>
          )}
        </DialogContent>
      </Dialog>
    </div>
  );
}
  • Step 2: Verify TypeScript compiles
cd ui && npx tsc --noEmit
Expected: no errors
  • Step 3: Commit
git add ui/src/components/superadmin/WorkerLogsPage.tsx
git commit -m "feat(ui): add WorkerLogsPage superadmin tab"

Task 9: Wire into Dashboard and Nav

Files:
  • Modify: ui/src/hooks/useNavItems.ts
  • Modify: ui/src/components/dashboards/SuperAdminDashboard.tsx
  • Step 1: Add nav item to useNavItems.ts
In ui/src/hooks/useNavItems.ts, find the 'System' section for the superadmin case:
{
    title: 'System',
    items: [
        { id: 'settings', label: 'Platform Settings', icon: ic(Settings2) },
        { id: 'cron-jobs', label: 'Cron Jobs', icon: ic(Timer) },
        { id: 'system', label: 'System Health', icon: ic(Activity) },
        { id: 'security', label: 'Security & 2FA', icon: ic(ShieldAlert) },
    ]
}
Add { id: 'worker-logs', label: 'Worker Logs', icon: ic(Server) } to the items array:
{
    title: 'System',
    items: [
        { id: 'settings', label: 'Platform Settings', icon: ic(Settings2) },
        { id: 'cron-jobs', label: 'Cron Jobs', icon: ic(Timer) },
        { id: 'system', label: 'System Health', icon: ic(Activity) },
        { id: 'security', label: 'Security & 2FA', icon: ic(ShieldAlert) },
        { id: 'worker-logs', label: 'Worker Logs', icon: ic(Server) },
    ]
}
Check if Server from lucide-react is already imported at the top of useNavItems.ts. If not, add it to the lucide import:
import { ..., Server } from 'lucide-react';
  • Step 2: Add import and view in SuperAdminDashboard.tsx
In ui/src/components/dashboards/SuperAdminDashboard.tsx: Add import near the other superadmin imports:
import WorkerLogsPage from '../superadmin/WorkerLogsPage';
Find the audit log render line:
{activeView === 'audit' && <AuditLogsPage />}
Add immediately after it:
{activeView === 'worker-logs' && <WorkerLogsPage />}
  • Step 3: Verify TypeScript compiles
cd ui && npx tsc --noEmit
Expected: no errors
  • Step 4: Commit
git add ui/src/hooks/useNavItems.ts ui/src/components/dashboards/SuperAdminDashboard.tsx
git commit -m "feat(ui): register Worker Logs tab in superadmin dashboard"

Task 10: Cloudflare Configuration

This task is performed in the Cloudflare Dashboard — no code changes.
  • Step 1: Set the OTLP_INGEST_SECRET Worker secret
cd server && wrangler secret put OTLP_INGEST_SECRET
Enter a strong random token (e.g. generate with openssl rand -hex 32). Note the value — you’ll paste it in the next step.
  • Step 2: Configure Cloudflare OTLP Log destination
  1. Go to Cloudflare Dashboard → Workers & Pages → your worker (odonto-prod)
  2. Click SettingsObservabilityAdd Destination
  3. Select Logs destination type
  4. Set OTLP Logs Endpoint: https://api.odontox.io/api/v1/otlp/logs
  5. Add custom header:
    • Header name: Authorization
    • Header value: Bearer <the secret you set in Step 1>
  6. Name it odontox-worker-logs and save
  • Step 3: Verify delivery
Trigger a Worker error manually (or wait for a real one) then check the superadmin “Worker Logs” tab. You should see the entry appear within a few seconds of the error occurring.

Self-Review

Spec coverage:
  • ✅ OTLP ingest endpoint at /api/v1/otlp/logs — Task 4
  • ✅ Bearer token auth via OTLP_INGEST_SECRET — Task 4
  • ✅ Filter severityNumber ≥ 17 on ingest — Task 3 (parser) + Task 4 (route)
  • ✅ Store in worker_logs PostgreSQL table — Tasks 1, 2, 4
  • ✅ 90-day TTL via daily cron — Task 6
  • GET /admin/worker-logs with pagination + filters — Task 5
  • WorkerLogsPage UI with table, filters, detail dialog — Task 8
  • ✅ “Worker Logs” nav item in superadmin sidebar — Task 9
  • ✅ Cloudflare dashboard configuration — Task 10
Placeholder scan: No TBDs, TODOs, or vague steps found. Type consistency:
  • WorkerLog type defined in serverComm.ts (Task 7) matches the workerLogs Drizzle schema (Task 1)
  • parseOtlpLogs returns Omit<NewWorkerLog, 'id' | 'receivedAt'>[] — matches the db.insert(workerLogs).values(rows) call in Task 4
  • WorkerLogsResponse fields (logs, total, page, totalPages) match the c.json(...) in Task 5
  • getWorkerLogs params match the query string parsing in Task 5