How to monitor a production deployment
Piko ships structured logging, OpenTelemetry hooks, health probes, Prometheus metrics, Sentry integration, and a notification service out of the tree. This guide wires them into a typical production setup. See the logger reference, health API reference, and notification reference for the authoritative surface.
Switch logging to JSON in production
The development default is a pretty, human-readable console logger. Production should emit structured JSON so log aggregators (Datadog, Splunk, Grafana Loki) can index the fields.
package main
import (
"os"
"piko.sh/piko"
"piko.sh/piko/wdk/logger"
)
func main() {
mode := piko.RunModeDev
if len(os.Args) > 1 {
mode = os.Args[1]
}
if mode == piko.RunModeProd {
logger.AddJSONOutput()
} else {
logger.AddPrettyOutput()
}
log := logger.GetLogger("myapp")
log.Info("Server started", logger.String("mode", mode))
// ...
}
Piko's logger uses a seven-level scheme with Piko-internal levels kept separate from user application levels:
| Level | Numeric | Zone | Typical use |
|---|---|---|---|
TRACE | -8 | Framework | Loop-level detail, variable dumps. |
INTERNAL | -6 | Framework | Service registration, cache ops, adapter lifecycle. |
DEBUG | -4 | User | Request/response detail, state dumps. |
INFO | 0 | User | Normal operations. Production default. |
NOTICE | 2 | Shared | Startup, shutdown, other lifecycle events. |
WARN | 4 | Shared | Recoverable issues, deprecations. |
ERROR | 8 | Shared | Failures that need attention. |
Set the level explicitly with piko.WithLogLevel("info") (or any of the names or numeric values above). The framework honours two environment variables at startup:
PIKO_LOG_LEVEL- read once by the framework's bootstrap logger before any options apply.WithLogLevelalways wins when set.LOG_LEVEL- read bylogger.AddPrettyOutput()andlogger.AddJSONOutput()in the scaffoldedcmd/main/main.go. Affects the user-facing logger you call in your own code.
PIKO_LOG_LEVEL=debug LOG_LEVEL=debug ./my-app prod
Add contextual attributes
Attach per-request attributes with With(...):
log := logger.GetLogger("myapp/handlers").With(
logger.String("request_id", getRequestID(r)),
logger.String("method", r.Method),
logger.String("path", r.URL.Path),
)
log.Info("Request received")
// ... process ...
log.Info("Request completed",
logger.Int("status_code", 200),
logger.Duration("duration", time.Since(start)),
)
Every log line in that handler now carries request_id, method, and path automatically.
Export traces and metrics via OpenTelemetry
Configure OTLP via the per-field options (piko.WithOTLPEnabled, piko.WithOTLPEndpoint, etc.). They thread through the OtlpConfig struct internally without you having to manage its pointer fields directly:
piko.New(
piko.WithOTLPEnabled(true),
piko.WithOTLPEndpoint("otel-collector:4317"),
piko.WithOTLPProtocol("grpc"),
piko.WithOTLPHeaders(map[string]string{
"Authorization": "Bearer " + os.Getenv("OTLP_TOKEN"),
}),
piko.WithOTLPInsecureTLS(false),
)
piko.WithOTLP(piko.OtlpConfig{...}) is also available if you prefer to build the struct yourself. Every settable field on OtlpConfig is a pointer (*bool, *string, *float64) and the insecure-TLS flag lives under OtlpConfig.TLS.Insecure instead of on the top-level struct. Use new(true) / new("...") to set them, or stick with the per-field options above.
Wrap units of work in spans so traces correlate with log lines:
func (s *OrderService) ProcessOrder(ctx context.Context, orderID string) error {
log := logger.GetLogger("myapp/orders")
return log.RunInSpan(ctx, "ProcessOrder", func(spanCtx context.Context, spanLog logger.Logger) error {
spanLog.Info("Processing order")
if err := s.validateOrder(spanCtx, orderID); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
if err := s.chargePayment(spanCtx, orderID); err != nil {
return fmt.Errorf("payment failed: %w", err)
}
spanLog.Info("Order completed")
return nil
}, logger.String("order_id", orderID))
}
RunInSpan creates the span, passes a span-scoped logger into the closure, ends the span on return, and records any returned error.
Wire health probes to Kubernetes
ssr := piko.New(
piko.WithHealthEnabled(true),
piko.WithHealthProbePort(9090),
piko.WithHealthBindAddress("0.0.0.0"),
piko.WithHealthLivePath("/live"),
piko.WithHealthReadyPath("/ready"),
piko.WithHealthMetricsPath("/metrics"),
piko.WithHealthMetricsEnabled(true),
piko.WithHealthCheckTimeout(5*time.Second),
)
The probe server runs on its own port, so application-level issues do not affect it.
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: myapp
ports:
- containerPort: 8080
name: http
- containerPort: 9090
name: health
livenessProbe:
httpGet:
path: /live
port: health
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: health
initialDelaySeconds: 5
periodSeconds: 5
The endpoints return JSON:
{
"name": "application",
"state": "HEALTHY",
"timestamp": "2026-04-21T10:00:00Z",
"duration": "1.234ms",
"dependencies": [
{"name": "database", "state": "HEALTHY", "duration": "0.5ms"}
]
}
State values: HEALTHY, DEGRADED, UNHEALTHY. Liveness returns 200 when healthy, 503 when unhealthy. Readiness returns 200 for healthy or degraded, 503 for unhealthy.
Register a custom health probe
Implement Check for each external dependency:
import (
"context"
"database/sql"
"time"
"piko.sh/piko/internal/healthprobe/healthprobe_dto"
)
type DatabaseProbe struct {
db *sql.DB
}
func (p *DatabaseProbe) Name() string { return "database" }
func (p *DatabaseProbe) Check(ctx context.Context, checkType healthprobe_dto.CheckType) healthprobe_dto.Status {
start := time.Now()
if err := p.db.PingContext(ctx); err != nil {
return healthprobe_dto.Status{
Name: p.Name(),
State: healthprobe_dto.StateUnhealthy,
Message: "database ping failed: " + err.Error(),
Timestamp: time.Now(),
Duration: time.Since(start).String(),
}
}
return healthprobe_dto.Status{
Name: p.Name(),
State: healthprobe_dto.StateHealthy,
Timestamp: time.Now(),
Duration: time.Since(start).String(),
}
}
Register it as a LifecycleHealthProbe during bootstrap. See the health checks how-to for the full registration pattern.
Scrape Prometheus metrics
piko.WithHealthMetricsEnabled(true) serves OpenTelemetry metrics at http://<host>:9090/metrics in Prometheus exposition format. Point your scraper at that endpoint:
scrape_configs:
- job_name: myapp
static_configs:
- targets: ["myapp.default.svc.cluster.local:9090"]
Piko emits HTTP request counters, latencies, process stats, and any custom metrics registered through the metrics exporter.
Enable Sentry error reporting
Import the Sentry integration and configure it at startup:
import (
"piko.sh/piko/wdk/logger"
sentry "piko.sh/piko/wdk/logger/logger_integration_sentry"
)
func main() {
sentry.Enable(sentry.Config{
DSN: os.Getenv("SENTRY_DSN"),
Environment: os.Getenv("APP_ENV"),
Release: buildVersion,
TracesSampleRate: 0.1,
SampleRate: 1.0,
})
logger.AddJSONOutput()
// ...
}
log.Error(...) calls automatically become Sentry events once sentry.Enable(...) has run.
Send alerts to a chat channel
Use the notification service for high-priority incidents. Piko's wdk/notification facade exposes the Service, the ProviderPort interface, the fluent NotificationBuilder, and provider-name constants (Slack, Discord, PagerDuty, Teams, Google Chat, ntfy.sh, Webhook, Stdout). The provider implementations themselves currently live under internal/, so user code cannot import them directly. The integration path is to implement notification.ProviderPort yourself (a webhook implementation is the smallest possible adapter) and register it at bootstrap:
piko.New(
piko.WithNotificationProvider("ops-webhook", myWebhookProvider),
piko.WithDefaultNotificationProvider("ops-webhook"),
)
The provider you pass must satisfy notification.ProviderPort. See the notification API reference for the full interface contract and the constants for each provider name.
Dispatch notifications from code:
builder, err := notification.NewNotificationBuilderFromDefault()
if err != nil {
return err
}
return builder.
Title("Payment provider down").
Message("Stripe returned 503 for the last 10 minutes.").
Priority(notification.PriorityHigh).
Do(ctx)
See the notification reference for the provider-name constants and the builder surface.
Forward log errors to notifications
Wire the logger and notifier together so ERROR entries become alerts automatically:
import (
"piko.sh/piko/internal/logger/logger_adapters/integrations"
"piko.sh/piko/wdk/notification"
)
notifyService, err := notification.GetDefaultService()
if err != nil {
return err
}
adapter := integrations.NewNotificationServiceAdapter(notifyService)
// Register `adapter` with the logger during startup.
Uptime monitoring
Point an external uptime service at one or both of:
https://<public-domain>/(end-to-end path through the application).http://<host>:9090/ready(readiness probe, cheaper, does not exercise the page stack).
Typical alert conditions include response time above 5 seconds, non-200 status, or downtime longer than one minute.
See also
- How to health checks for custom probes.
- Logger API reference.
- Health API reference.
- Notification API reference.
- How to profiling for CPU and allocation analysis.
- How to troubleshooting deployment.