Install
$ agentstack add skill-san-npm-skills-ws-monitoring-observability ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Monitoring & Observability
The Three Pillars — And How They Connect
Monitoring tells you something is broken. Observability tells you why.
Alert fires (metric) → Find error spike in dashboard (metric)
→ Filter logs by time window (logs) → Find correlation ID
→ Trace the request across services (traces) → Find the slow DB query
Metrics: Aggregated numbers over time. Cheap to store, good for alerting. Logs: Individual events with context. Expensive at scale, essential for debugging. Traces: Request flow across services. The connective tissue between metrics and logs.
The key insight: correlation. Every log line and trace should carry the same request ID so you can jump between pillars seamlessly.
Structured Logging That Actually Helps
The Pattern
What ships to production must be structured (JSON), so a log pipeline can index and query it. No console.log("user signed up") in app code. Locally, pretty-print for human eyes — but only at the sink, never by changing what the app emits: pipe through pino-pretty in dev (node app.js | pino-pretty) or set transport: { target: 'pino-pretty' } behind a NODE_ENV !== 'production' guard. The emitted log object stays identical; only rendering differs.
// lib/logger.ts
import pino from 'pino';
import { trace, context } from '@opentelemetry/api';
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level(label) {
return { level: label }; // "info" not 30
},
},
serializers: {
err: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
// Stamp every line with the active trace/span so logs link to traces.
// This is what the Loki `derivedFields` regex (`"trace_id":"(\w+)"`) and the
// Tempo `tracesToLogsV2` link rely on — without it, trace↔log jumps are dead.
mixin() {
const span = trace.getSpan(context.active());
if (!span) return {};
const { traceId, spanId } = span.spanContext();
return { trace_id: traceId, span_id: spanId };
},
// Add service metadata to every log
base: {
service: process.env.SERVICE_NAME || 'api',
version: process.env.APP_VERSION || 'unknown',
environment: process.env.NODE_ENV || 'development',
},
});
// Request-scoped logger with correlation ID
export function createRequestLogger(requestId: string, userId?: string) {
return logger.child({
requestId,
userId,
});
}
Express Middleware
import { randomUUID } from 'crypto';
import { createRequestLogger } from './logger';
app.use((req, res, next) => {
const requestId = req.headers['x-request-id'] as string || randomUUID();
req.log = createRequestLogger(requestId, req.user?.id);
res.setHeader('x-request-id', requestId);
const start = performance.now();
res.on('finish', () => {
const duration = performance.now() - start;
req.log.info({
method: req.method,
url: req.originalUrl,
statusCode: res.statusCode,
duration: Math.round(duration),
contentLength: res.getHeader('content-length'),
}, 'request completed');
});
next();
});
Log Levels That Actually Mean Something
| Level | When to Use | Example | |-------|-------------|---------| | fatal | Process is about to crash | Uncaught exception, out of memory | | error | Operation failed, needs attention | Payment processing failed, DB connection lost | | warn | Something unexpected, but handled | Rate limit approaching, deprecated API called | | info | Business events worth recording | User signed up, order placed, deploy completed | | debug | Technical details for debugging | SQL queries, cache hit/miss, request/response bodies | | trace | Extremely verbose, rarely enabled | Function entry/exit, variable values |
Rule of thumb: If you'd want to see it in production logs during an incident, it's info. If you'd only want it when actively debugging, it's debug.
But logs are not your business-analytics pipeline. High-volume, high-cardinality business events (every page view, every cache lookup, per-item loop iterations) should NOT be info logs — they blow up ingestion cost and bury signal. Instead:
- Count them as metrics (
Counter/Histogram) —signups_total,orders_total{status}— and log only the exceptional cases. - Sample routine successes if you must log them: log 1-in-N, or log the slow/failed tail only.
- Reserve
infofor events you'd actually read one-by-one during an incident (deploys, config changes, a payment that failed). A useful budget: an idle service should emit roughly zeroinfolines per second.
Prometheus: PromQL Deep Dive
Metric Types and When to Use Each
import { Counter, Histogram, Gauge, Summary, Registry } from 'prom-client';
const registry = new Registry();
// Counter: things that only go up
// Use for: requests, errors, bytes transferred
const httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'path', 'status_code'] as const,
registers: [registry],
});
// Histogram: distribution of values (request duration, response size)
// Use for: latency, size — anything you want percentiles of
const httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'path', 'status_code'] as const,
buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [registry],
});
// Gauge: values that go up and down
// Use for: queue depth, active connections, temperature
const activeConnections = new Gauge({
name: 'active_connections',
help: 'Number of active connections',
registers: [registry],
});
// In your request handler:
app.use((req, res, next) => {
activeConnections.inc();
const end = httpRequestDuration.startTimer({
method: req.method,
path: routePattern(req), // "/users/:id" not "/users/12345"
});
res.on('finish', () => {
const labels = { method: req.method, path: routePattern(req), status_code: String(res.statusCode) };
httpRequestsTotal.inc(labels);
end({ status_code: String(res.statusCode) });
activeConnections.dec();
});
next();
});
// Expose metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', registry.contentType);
res.end(await registry.metrics());
});
PromQL: Queries You'll Actually Use
# Request rate (requests per second over last 5 minutes)
rate(http_requests_total[5m])
# Error rate as a percentage
sum(rate(http_requests_total{status_code=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m]))
* 100
# P95 latency
histogram_quantile(0.95,
sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
)
# P95 latency per endpoint
histogram_quantile(0.95,
sum(rate(http_request_duration_seconds_bucket[5m])) by (le, path)
)
# Apdex score (satisfied On managed clusters, prefer **Prometheus Operator** `ServiceMonitor`/`PodMonitor` CRDs over hand-written `kubernetes_sd_configs` — same discovery, declarative and per-team.
### Long-Term Storage: Remote Write & Retention
Local TSDB is for recent data (the compose example keeps `30d`). For long retention, HA, and global query, **remote-write** to a long-term backend (Mimir, Thanos, Cortex, or a vendor) instead of growing local disk forever:
```yaml
# add to prometheus.yml
remote_write:
- url: https://mimir.internal/api/v1/push
queue_config:
max_shards: 50 # cap fan-out so a backend stall can't OOM Prometheus
capacity: 10000
# Don't ship churny, high-cardinality series to long-term storage:
write_relabel_configs:
- source_labels: [__name__]
regex: 'go_gc_.*|process_.*'
action: drop
Retention is controlled by flags, not config: --storage.tsdb.retention.time=30d (and/or --storage.tsdb.retention.size=50GB, whichever trips first). Rule of thumb for local disk: ~1-3 bytes/sample after compression × samples/s × retention.
Cardinality Budget — the #1 way to blow up Prometheus
Every unique combination of label values is a separate time series. A single high-cardinality label (user_id, request_id, raw url, email) can create millions of series and OOM the server. Budget it and watch it:
# Total active series (your headline number — track it on a dashboard)
prometheus_tsdb_head_series
# Which metric names have the most series? (run in the Prometheus UI)
topk(10, count by (__name__)({__name__=~".+"}))
# Cardinality of a label across one metric — catch the offender
count(count by (path) (http_requests_total)) # how many distinct `path` values?
# Series being created/churned per second (high churn = expensive)
rate(prometheus_tsdb_head_series_created_total[5m])
Guardrails: keep labelNames small and bounded (templated paths like /users/:id, never raw IDs); set sample_limit per scrape job to fail loudly instead of silently exploding; drop noisy series with metric_relabel_configs. Treat any unbounded-value label as a bug.
Recording Rules
Pre-compute expensive queries to speed up dashboards and to back multi-window SLO alerts. The error-ratio is recorded at every window the burn-rate alerts reference (5m/30m/1h/6h).
# prometheus/recording-rules.yml
groups:
- name: http_metrics
interval: 15s
rules:
- record: job:http_requests:rate5m
expr: sum(rate(http_requests_total[5m])) by (job)
- record: job:http_errors:rate5m
expr: sum(rate(http_requests_total{status_code=~"5.."}[5m])) by (job)
- record: job:http_error_ratio:rate5m
expr: |
job:http_errors:rate5m / job:http_requests:rate5m
# Extra windows so the burn-rate alerts below are self-contained.
- record: job:http_error_ratio:rate30m
expr: |
sum(rate(http_requests_total{status_code=~"5.."}[30m])) by (job)
/ sum(rate(http_requests_total[30m])) by (job)
- record: job:http_error_ratio:rate1h
expr: |
sum(rate(http_requests_total{status_code=~"5.."}[1h])) by (job)
/ sum(rate(http_requests_total[1h])) by (job)
- record: job:http_error_ratio:rate6h
expr: |
sum(rate(http_requests_total{status_code=~"5.."}[6h])) by (job)
/ sum(rate(http_requests_total[6h])) by (job)
- record: job:http_latency:p95_5m
expr: |
histogram_quantile(0.95,
sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job)
)
- record: job:http_latency:p99_5m
expr: |
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job)
)
Alerting Rules
# prometheus/alerting-rules.yml
groups:
- name: availability
rules:
- alert: HighErrorRate
expr: job:http_error_ratio:rate5m > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "High error rate on {{ $labels.job }}"
description: "Error rate is {{ $value | humanizePercentage }} (threshold: 5%)"
runbook: "https://wiki.internal/runbooks/high-error-rate"
- alert: HighLatency
expr: job:http_latency:p95_5m > 1
for: 10m
labels:
severity: warning
annotations:
summary: "High P95 latency on {{ $labels.job }}"
description: "P95 latency is {{ $value | humanizeDuration }}"
- alert: PodCrashLooping
expr: |
increase(kube_pod_container_status_restarts_total[1h]) > 5
for: 5m
labels:
severity: critical
annotations:
summary: "Pod {{ $labels.pod }} crash looping"
- alert: DiskSpaceLow
expr: |
(node_filesystem_avail_bytes / node_filesystem_size_bytes) Editing dashboards in the UI then committing the exported JSON is the normal loop. Strip the volatile `id`, `version`, and `__inputs` fields before committing so diffs stay clean, and keep a stable `uid` so deep links and alert annotations survive re-imports.
### Alertmanager Routing
```yaml
# alertmanager/alertmanager.yml
global:
resolve_timeout: 5m
route:
group_by: ['alertname', 'job']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: 'slack-default'
routes:
# Modern Alertmanager uses `matchers:` (list of label-matcher strings).
# The legacy `match:`/`match_re:` maps are deprecated — don't use them.
- matchers:
- severity = "critical"
receiver: 'pagerduty-critical'
repeat_interval: 1h
- matchers:
- severity = "warning"
receiver: 'slack-warnings'
repeat_interval: 4h
receivers:
- name: 'slack-default'
slack_configs:
- api_url: '$SLACK_WEBHOOK_URL' # store the real webhook in a secret, not in git
channel: '#alerts'
title: '{{ .GroupLabels.alertname }}'
text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
- name: 'pagerduty-critical'
pagerduty_configs:
# PagerDuty Events API v2 uses `routing_key` (the Integration Key from a
# service's "Events API v2" integration). `service_key` is the legacy v1 field.
- routing_key: '$PAGERDUTY_ROUTING_KEY'
severity: '{{ if eq .CommonLabels.severity "critical" }}critical{{ else }}error{{ end }}'
description: '{{ .GroupLabels.alertname }}: {{ .CommonAnnotations.summary }}'
- name: 'slack-warnings'
slack_configs:
- api_url: '$SLACK_WARN_WEBHOOK_URL'
channel: '#alerts-warnings'
OpenTelemetry: Auto-Instrumentation
Node.js Setup
> APIs below target OpenTelemetry JS 2.x (the line shipping since early 2025). The biggest gotcha vs. 1.x: the Resource class is no longer exported — use the resourceFromAttributes() / defaultResource() functions. If you're on 1.x and can't upgrade yet, swap those for new Resource({...}).
// tracing.ts — the SDK must start BEFORE any instrumented library is required.
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
// OTel JS 2.x: build the Resource with the helper, not `new Resource(...)`.
import { resourceFromAttributes, defaultResource } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
} from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
// merge over the default resource so process/host/SDK attributes are kept.
resource: defaultResource().merge(
resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.SERVICE_NAME || 'api',
[ATTR_SERVICE_VERSION]: process.env.APP_VERSION || '0.0.0',
[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: process.env.NODE_ENV || 'development',
}),
),
// Point at the Collector's OTLP/HTTP ingress (otel-collector:4318), not Tempo directly.
// OTEL_EXPORTER_OTLP_ENDPOINT should be the BASE url; the SDK appends /v1/traces etc.
traceExporter: new OTLPTraceExporter(),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter(),
exportIntervalMillis: 15000,
}),
instrumentations: [
getNodeAutoInstrumentations({
// ignoreIncomingRequestHook replaces the removed ignoreIncomingPaths option.
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) =>
['/healthz', '/ready', '/metrics'].includes(req.url ?? ''),
},
'@opentelemetry/instrumentation-fs': { enabled: false },
}),
],
});
sdk.start();
process.on('SIGT
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [san-npm](https://github.com/san-npm)
- **Source:** [san-npm/skills-ws](https://github.com/san-npm/skills-ws)
- **License:** MIT
- **Homepage:** https://skills-ws.vercel.app
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.