Skip to content

Monitoring and Observability

Production apps fail in ways that are invisible without instrumentation. Slingshot emits structured JSON logs, exposes a Prometheus /metrics endpoint, and publishes security-relevant auth events on the event bus.


Every request logs as a single JSON line to stdout.

app.config.ts (excerpt)
export default defineApp({
port: 3000,
logging: {
level: 'info', // 'debug' | 'info' | 'warn' | 'error' | 'silent'
format: 'json', // 'json' (default in production) | 'pretty' (default in dev)
requests: true, // log every request (default: true)
},
// ...
});
{
"level": "info",
"time": "2025-09-01T12:34:56.789Z",
"msg": "request",
"method": "POST",
"url": "/api/auth/login",
"status": 200,
"duration": 42,
"requestId": "01HZ...",
"tenantId": "acme",
"userId": "usr_abc123",
"ip": "203.0.113.42",
"userAgent": "Mozilla/5.0 ..."
}

requestId generates per-request and propagates through the context. Attach it to downstream calls for correlation.

tenantId and userId populate only after tenant middleware and auth middleware run — absent on unauthenticated routes.

LevelWhat it covers
debugRequest/response body snapshots, DB query plans, cache hits/misses
infoEvery request, plugin startup, migration runs
warnRetried operations, degraded mode, non-fatal auth events
errorUnhandled exceptions, plugin setup failures, DB connection errors
silentNo output — use this in tests
import { createApp } from '@lastshotlabs/slingshot';
const { app } = await createApp({
logging: { enabled: false },
// ...
});

Use logging.enabled: false in tests when you need deterministic output.


Enable the /metrics endpoint with one config key:

app.config.ts (excerpt)
export default defineApp({
metrics: {
enabled: true,
path: '/metrics', // default
// Restrict scraping to internal network or a bearer token:
auth: { type: 'bearer', token: process.env.METRICS_TOKEN },
},
});
MetricTypeLabelsDescription
slingshot_http_requests_totalCountermethod, route, statusTotal request count
slingshot_http_request_duration_msHistogrammethod, route, statusRequest latency in milliseconds
slingshot_http_errors_totalCountermethod, route, status4xx and 5xx responses
slingshot_active_connectionsGaugeCurrent open HTTP connections
slingshot_auth_login_totalCounteroutcome (success, failure)Auth login attempts
slingshot_auth_session_duration_sHistogramSession lifetimes
slingshot_event_bus_emit_totalCountereventEvents emitted on the bus
slingshot_db_query_duration_msHistogramadapter, operationAdapter query latency
process_cpu_seconds_totalCounterStandard Node/Bun process metrics
process_resident_memory_bytesGaugeRSS

Plugin metrics use the plugin name as prefix: slingshot_community_thread_created_total, slingshot_push_notification_sent_total, etc.

prometheus.yml
scrape_configs:
- job_name: slingshot
scrape_interval: 15s
static_configs:
- targets: ['your-app-host:3000']
metrics_path: /metrics
bearer_token: '<METRICS_TOKEN>'

For ECS or Kubernetes, use service discovery instead of static targets:

scrape_configs:
- job_name: slingshot
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
regex: your-app
action: keep
bearer_token: '<METRICS_TOKEN>'

Import the community Slingshot dashboard (ID XXXXX) or build your own from these panels:

  • Request raterate(slingshot_http_requests_total[1m]) grouped by route
  • P95 latencyhistogram_quantile(0.95, rate(slingshot_http_request_duration_ms_bucket[5m]))
  • Error raterate(slingshot_http_errors_total[1m]) / rate(slingshot_http_requests_total[1m])
  • Auth failuresrate(slingshot_auth_login_total{outcome="failure"}[5m])
  • DB latencyhistogram_quantile(0.95, rate(slingshot_db_query_duration_ms_bucket[5m])) by adapter

Slingshot mounts /health by default. It returns 200 whenever the process is running and is suitable for load balancer liveness checks.

{
"status": "ok"
}

Slingshot also mounts /health/ready. It returns 200 when configured dependencies are ready and 503 when a dependency check fails. Postgres readiness is included automatically when db.postgres is configured and the Postgres bundle exposes its health check.

{
"status": "ok",
"checks": {
"postgres": {
"ok": true
}
}
}
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 10
periodSeconds: 15
failureThreshold: 3

When the readiness probe returns 503, the pod drops from the load balancer’s target group without being killed — useful during DB failovers.


slingshot-auth emits an event on the framework event bus for every security-relevant action. Subscribe in setupPost to forward to your audit log or SIEM.

Event keyEmitted when
auth:user.loginSuccessful password login
auth:user.login_failedFailed login attempt
auth:user.logoutExplicit logout
auth:user.registeredNew account created
auth:user.password_changedPassword updated
auth:user.password_reset_requestedPassword reset email sent
auth:user.mfa_enabledMFA activated on an account
auth:user.mfa_disabledMFA removed
auth:user.mfa_failedMFA challenge failed
auth:user.account_lockedAccount locked after repeated failures
auth:session.revokedSession explicitly revoked
auth:oauth.linkedOAuth provider linked to account
auth:oauth.loginLogin via OAuth provider
auth:m2m.key_createdAPI key created (if slingshot-m2m installed)
auth:m2m.key_revokedAPI key revoked
src/plugins/securityAudit.ts
import { type SecurityEvent, createAuthPlugin } from '@lastshotlabs/slingshot-auth';
createAuthPlugin({
securityEvents: {
onEvent: async (event: SecurityEvent) => {
await shipToSiem({
eventType: event.eventType,
severity: event.severity,
userId: event.userId,
ip: event.ip,
userAgent: event.userAgent,
timestamp: event.timestamp,
metadata: event.meta,
});
},
exclude: ['security.auth.login.success'],
},
});
async function shipToSiem(entry: Record<string, unknown>) {
// POST to your SIEM ingest endpoint, write to a structured log, etc.
console.log(JSON.stringify({ level: 'security', ...entry }));
}

Register this plugin in your app.config.ts before other plugins — it has no setup dependencies.


import type { Context } from 'hono';
import { createApp } from '@lastshotlabs/slingshot';
import { getActorId } from '@lastshotlabs/slingshot-core';
declare function reportError(err: Error, context: Record<string, unknown>): void;
const { app } = await createApp({
// ...
});
app.onError((err: Error, c: Context) => {
reportError(err, {
requestId: c.get('requestId'),
userId: getActorId(c),
url: c.req.url,
method: c.req.method,
});
// Let Slingshot's default error handler format the response
return c.json({ error: 'internal_server_error' }, 500);
});

createApp returns the app instance. Register onError after startup to wrap Slingshot’s own error handling.

app.onError((err, c) => {
console.error(
JSON.stringify({
level: 'error',
msg: err.message,
stack: err.stack,
requestId: c.get('requestId'),
url: c.req.url,
method: c.req.method,
time: new Date().toISOString(),
}),
);
return c.json({ error: 'internal_server_error' }, 500);
});

Slingshot propagates requestId through the request context. For span-level distributed tracing, integrate OpenTelemetry. Start your tracer provider in a custom entry script before invoking the framework — for example, register the SDK and then dynamically import app.config.ts:

src/start.ts
declare const tracing: {
start(): Promise<void> | void;
};
await tracing.start();
await import('../app.config');

OpenTelemetry is the common choice here: start your tracer provider before the framework boots, then add the HTTP and database instrumentations that match your runtime and drivers.

For Bun, use @opentelemetry/sdk-node with Bun’s compatibility layer. Full Bun-native OTEL support is pending upstream — check the OpenTelemetry Bun issue for current status.


Slingshot logs to stdout as newline-delimited JSON. Any log shipper that reads stdout forwards them without extra parsing configuration.

datadog-agent.yaml
logs:
- type: docker
service: slingshot-api
source: slingshot
sourcecategory: backend

Datadog auto-parses JSON logs. The msg, level, requestId, userId, and duration fields index as facets by default.

The generated sst.config.ts sets up CloudWatch log groups with:

slingshot.platform.ts
import { definePlatform } from '@lastshotlabs/slingshot-infra';
export default definePlatform({
org: 'acme',
provider: 'aws',
region: 'us-east-1',
registry: {
provider: 'local',
path: '.slingshot/registry.json',
},
stages: {
prod: {
env: {
NODE_ENV: 'production',
},
},
},
defaults: {
logging: {
driver: 'cloudwatch',
retentionDays: 30,
},
},
});

JSON log lines land in CloudWatch as structured records. Query them with CloudWatch Insights:

fields @timestamp, method, url, status, duration, userId
| filter status >= 500
| sort @timestamp desc
| limit 100
promtail.yaml
scrape_configs:
- job_name: slingshot
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: ['__meta_docker_container_name']
regex: '.*slingshot.*'
action: keep
pipeline_stages:
- json:
expressions:
level: level
requestId: requestId
status: status
duration: duration
- labels:
level:
status:

LogQL query for P95 latency:

quantile_over_time(0.95, {job="slingshot"} | json | duration != "" | unwrap duration [5m])