Skip to main content

Telemetry & Monitoring

Radish CLI generates a complete telemetry system that tracks HTTP requests, service calls, external API calls, and application health — all with zero dependencies.

Overview

Every generated app includes:

  • Request tracking — inbound HTTP requests (method, path, duration, status)
  • Service instrumentation — all CRUD operations timed and reported
  • External API tracking — third-party calls (Dynamics 365, BigCommerce, Stripe, etc.)
  • Correlation IDs — trace a single request across the full stack
  • Structured logging — timestamped, correlation-ID-aware log entries
  • Health endpointGET /api/health with aggregated stats
  • Admin monitoring page — live dashboard at /admin/health
  • Hub reporting — push metrics to Radish Hub for historical dashboards

Quick Start

Telemetry is always-on. No configuration needed for basic monitoring.

Track external API calls

import { trackedFetch } from '$lib/telemetry';

// Wrap any outbound fetch — identical API to fetch() plus service name
const res = await trackedFetch('dynamics365',
'https://org.api.crm.dynamics.com/api/data/v9.2/accounts',
{
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
},
{ correlationId: locals.correlationId }
);

That's it. The call is now tracked — duration, status, success/error — and visible in:

  • /api/health response (externalCallMetrics array)
  • Admin panel at /admin/health (External API Calls table)
  • Radish Hub dashboard (if Hub reporting is enabled)

Enable Hub reporting

Add to .env:

RADISH_HUB_URL=https://hub.radishplatform.com
RADISH_HUB_TOKEN=rhub_your_token_here

# Optional: identify this app
APP_SLUG=commercehub
APP_NAME=CommerceHub
APP_URL=https://commercehub.radishplatform.com

The app auto-registers with Hub on first push. No manual linking needed. Works with any deployment method (Hub, Coolify, Docker, bare metal).

Hub collects metrics every 60 seconds and provides:

  • Historical trend charts (response time, error rate, external call duration over days/weeks)
  • Cross-app comparison (compare D365 performance across instances)
  • Alerting (error rate spikes)

Architecture

Inbound Request

├─ telemetryHandle (middleware)
│ ├─ Generate correlation ID
│ ├─ Track request duration + status
│ └─ Set x-correlation-id response header

├─ Service Layer
│ └─ instrument() wraps every CRUD method
│ └─ trackServiceCall(entity, action, duration, success)

├─ External API Calls (opt-in)
│ └─ trackedFetch('service', url, init, { correlationId })
│ └─ trackExternalCall(service, endpoint, duration, status)

└─ All metrics flow to TelemetryAdapter
├─ ConsoleAdapter (default) — in-memory ring buffer
├─ Hub Reporter — pushes stats every 60s
└─ Custom adapters — Prometheus, Datadog, etc.

Tracking External API Calls

trackedFetch(service, input, init?, context?)

Drop-in replacement for fetch() with telemetry. Use it for any outbound HTTP call you want to monitor.

import { trackedFetch } from '$lib/telemetry';

// Basic usage
const res = await trackedFetch('stripe', 'https://api.stripe.com/v1/charges', {
method: 'POST',
headers: { 'Authorization': `Bearer ${stripeKey}` },
body: new URLSearchParams({ amount: '2000', currency: 'usd' })
});

// With correlation ID (recommended — enables cross-service tracing)
const res = await trackedFetch('bigcommerce', url, init, {
correlationId: locals.correlationId
});

// Disable correlation ID propagation (if the external API doesn't support it)
const res = await trackedFetch('legacy-api', url, init, {
correlationId: locals.correlationId,
propagateCorrelationId: false
});

What gets tracked per call:

FieldDescription
serviceName you provide ('dynamics365', 'bigcommerce', etc.)
endpointURL pathname (query params stripped for grouping)
methodHTTP method
statusHTTP status code
durationTime in milliseconds
successtrue if status < 400
errorError message (if fetch throws)
correlationIdLinks to the inbound request that triggered this call

Slow call logging: External calls >2 seconds are automatically logged as warnings with the structured logger.

Where to use trackedFetch

Use it anywhere you call a third-party API from server-side code:

// In a service method
class OrderService extends BaseService {
async syncToErp(orderId: string) {
const order = await this.get(orderId);

const res = await trackedFetch('dynamics365',
`${D365_URL}/api/data/v9.2/salesorders`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${await getD365Token()}` },
body: JSON.stringify(mapOrderToD365(order))
},
{ correlationId: this.auth.correlationId }
);

if (!res.ok) throw new Error(`D365 sync failed: ${res.status}`);
return res.json();
}
}
// In a SvelteKit server route
export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json();

const webhookRes = await trackedFetch('slack',
'https://hooks.slack.com/services/xxx',
{
method: 'POST',
body: JSON.stringify({ text: body.message })
},
{ correlationId: locals.correlationId }
);

return json({ sent: webhookRes.ok });
};
// In a background job handler
async function processWebhook(payload, context) {
const res = await trackedFetch('payment-gateway',
`${GATEWAY_URL}/api/v1/verify`,
{ method: 'POST', body: JSON.stringify(payload) }
// No correlationId needed — background job has no inbound request
);
}

Raw Time-Series Data

For custom dashboards or exports, getExternalCallTimeline() returns the raw ring buffer of external call metrics:

import { getTelemetry } from '@core/telemetry';

const telemetry = getTelemetry();

// All external calls
const timeline = telemetry.getExternalCallTimeline();

// Filter by service
const d365Calls = telemetry.getExternalCallTimeline('dynamics365');

Each entry in the timeline is an ExternalCallMetric:

{
service: string; // 'dynamics365', 'bigcommerce', 'stripe'
endpoint: string; // URL pathname (query stripped)
method: string; // 'GET', 'POST', etc.
status: number; // HTTP status code
duration: number; // Milliseconds
success: boolean;
error?: string;
correlationId?: string;
timestamp: Date;
}

Use cases:

  • Export to CSV for analysis
  • Build custom latency charts
  • Compare performance across API versions
  • Debug slow requests by correlation ID

Structured Logger

The structured logger produces timestamped, correlation-ID-aware log entries:

import { createLogger } from '@core/telemetry/logger';

// Create a logger with a correlation ID
const logger = createLogger(correlationId);

logger.info('Order processed', { orderId: '123', total: 49.99 });
logger.warn('Inventory low', { sku: 'ABC-001', remaining: 3 });
logger.error('Payment failed', { provider: 'stripe', code: 'card_declined' });

Output format:

[2026-07-21T14:30:00.000Z] INFO [a1b2c3d4-e5f6-7890] Order processed {"orderId":"123","total":49.99}

The logger replaces scattered console.log/error/warn calls with structured output that includes timestamps, log levels, and correlation IDs for filtering.

Service Instrumentation

Every generated service method is wrapped with this.instrument(), which:

  1. Records the operation start time
  2. Calls the telemetry adapter's trackService() with entity, action, and duration
  3. Logs errors with the correlation-aware logger
  4. Reports success/failure status
// Inside a generated service (simplified):
async list(criteria) {
return this.instrument('list', async () => {
// ... actual business logic
});
}

Instrumentation is transparent — it adds no behavior changes, only observability. The telemetry adapter receives:

  • entity — which entity was operated on (e.g., 'Product')
  • action — which method was called (e.g., 'list', 'create', 'update')
  • duration — milliseconds elapsed
  • success — whether the operation completed without throwing

Health Endpoint

GET /api/health returns current telemetry stats:

{
"uptime": 86400,
"requestCount": 15230,
"errorCount": 12,
"avgResponseTime": 145,
"errorRate": 0.08,
"dbStatus": "connected",
"memoryUsage": { "heapUsed": 85, "heapTotal": 120, "rss": 145 },
"slowestEndpoints": [
{ "path": "/api/v1/orders", "avgMs": 890, "count": 342 },
{ "path": "/api/v1/products/:id", "avgMs": 420, "count": 1205 }
],
"serviceMetrics": [
{ "entity": "order", "action": "list", "avgMs": 45, "count": 342, "errorCount": 0 },
{ "entity": "product", "action": "get", "avgMs": 12, "count": 1205, "errorCount": 2 }
],
"externalCallMetrics": [
{ "service": "dynamics365", "endpoint": "/api/data/v9.2/accounts", "avgMs": 820, "count": 47, "errorCount": 2 },
{ "service": "bigcommerce", "endpoint": "/v3/products", "avgMs": 150, "count": 312, "errorCount": 0 }
],
"recentErrors": [
{ "message": "D365 timeout", "path": "/api/v1/sync", "timestamp": "2026-07-09T..." }
]
}

Admin Monitoring Page

The generated admin panel includes a live monitoring dashboard at /admin/health:

  • Stat cards — uptime, request count, avg response time, error rate, DB status, memory
  • Slowest endpoints table — which API routes are slowest
  • Service metrics table — entity CRUD operation performance
  • External API calls table — third-party API performance by service and endpoint
  • Recent errors — last 10 errors with path context

Auto-refreshes every 30 seconds. No charting library needed — DaisyUI tables and stat cards.

Hub Reporting

When RADISH_HUB_URL and RADISH_HUB_TOKEN are set in .env, the app pushes telemetry snapshots to Radish Hub every 60 seconds.

How it works

  1. startHubReporter() is called in hooks.server.ts (auto-generated)
  2. Every 60 seconds, it calls getStatsAndReset() on the telemetry adapter
  3. getStatsAndReset() returns stats for that interval and resets counters (no double-counting)
  4. Stats are POSTed to POST /api/telemetry/push on Hub
  5. Hub stores the snapshot with a TTL index (auto-deletes after 30 days)

Auto-registration

The first push auto-registers the app with Hub. No manual linking needed. Set APP_SLUG, APP_NAME, and APP_URL in .env for proper identification.

What Hub provides

  • Historical trends — response time, error rate, external call duration over days/weeks
  • Cross-app comparison — compare D365 performance across CommerceHub, finances, etc.
  • External call isolation — "is the app slow, or is D365 slow?"
  • Time range queries — 1h, 6h, 24h, 7d, 30d views

Configuration

# .env — add these to enable Hub reporting

# Required
RADISH_HUB_URL=https://hub.radishplatform.com
RADISH_HUB_TOKEN=rhub_your_token

# Recommended — identifies this app in Hub's dashboard
APP_SLUG=commercehub
APP_NAME=CommerceHub
APP_URL=https://commercehub.radishplatform.com

If these env vars are not set, the reporter silently does nothing. Zero impact on apps that don't use Hub.

Correlation IDs

Every inbound request gets a unique correlation ID (UUID v4). It flows through the entire stack:

Request → x-correlation-id header (or auto-generated)
→ event.locals.correlationId
→ RequestContext.auth.correlationId
→ ServiceAuth.correlationId
→ BaseService.instrument() → telemetry
→ trackedFetch() → outbound x-correlation-id header
→ Response x-correlation-id header

Inbound: If the request includes an x-correlation-id header (from an API gateway or upstream service), it's used. Otherwise, a new UUID is generated.

Outbound: trackedFetch() auto-sets x-correlation-id on outbound requests, enabling cross-service tracing.

In logs: Every structured log entry includes the correlation ID:

[2026-07-09T14:30:00.123Z] ERROR [abc-123-def] D365 sync failed {"status": 500, "entity": "order"}

Custom Adapters

The telemetry system uses an adapter pattern. The default ConsoleAdapter works with zero dependencies. You can swap or combine adapters.

Using Multiple Adapters

// hooks.server.ts
import { setTelemetryAdapter } from '$lib/telemetry';
import { ConsoleAdapter } from '$lib/telemetry/adapters/console.adapter';
import { MultiAdapter } from '$lib/telemetry/adapters/multi.adapter';

setTelemetryAdapter(new MultiAdapter([
new ConsoleAdapter(), // In-memory stats + health endpoint
new MyCustomAdapter() // Your own backend
]));

Writing a Custom Adapter

Implement the TelemetryAdapter interface:

import type { TelemetryAdapter, RequestMetric, ServiceMetric, ExternalCallMetric, TelemetryStats } from '$lib/telemetry';

export class DatadogAdapter implements TelemetryAdapter {
trackRequest(data: RequestMetric): void {
// Send to Datadog
}

trackServiceCall(data: ServiceMetric): void {
// Send to Datadog
}

trackExternalCall(data: ExternalCallMetric): void {
// Send to Datadog — track D365 calls separately
}

trackError(error: Error, context?: Record<string, any>): void {
// Report to Datadog error tracking
}

trackEvent(name: string, data?: Record<string, any>): void {
// Custom events
}

async flush(): Promise<void> {
// Flush buffered metrics
}

async getStats(): Promise<TelemetryStats> {
// Return aggregated stats for /api/health
}
}

Next Steps

  • Services — service layer with instrumented CRUD methods
  • Routes — HTTP API with correlation ID headers
  • Access Control — permissions and authorization