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:

LevelNumericZoneTypical use
TRACE-8FrameworkLoop-level detail, variable dumps.
INTERNAL-6FrameworkService registration, cache ops, adapter lifecycle.
DEBUG-4UserRequest/response detail, state dumps.
INFO0UserNormal operations. Production default.
NOTICE2SharedStartup, shutdown, other lifecycle events.
WARN4SharedRecoverable issues, deprecations.
ERROR8SharedFailures 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. WithLogLevel always wins when set.
  • LOG_LEVEL - read by logger.AddPrettyOutput() and logger.AddJSONOutput() in the scaffolded cmd/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