epok

Send Railway logs to Epok

Railway doesn't have a built-in HTTP log drain — there's no dashboard setting to forward stdout to an external endpoint. The two working paths are both app-side: push logs directly from your service, or instrument with OpenTelemetry (vendor-portable). Both run inside your existing Railway service — Railway doesn't support sidecars.

Time to first log: 5–10 min · Trial: 14 days, no card · API key: app.getepok.dev → Settings → API Keys

Path 1: Direct HTTP push from app code

Simplest path. Set EPOK_API_KEYas a Railway environment variable, then have your app POST log batches to Epok's Elasticsearch Bulk endpoint. The examples below enrich each entry with Railway's built-in environment variables so logs are pre-tagged with service / deploy / region.

Required Railway environment variables

Set EPOK_API_KEY in the Railway service's Variables tab. Railway already injects the identity vars below into every service at runtime — use them to tag your logs:

text
EPOK_API_KEY            = epk_REPLACE_ME    # you set this
RAILWAY_SERVICE_NAME    = (auto)            # e.g. "api-gateway"
RAILWAY_ENVIRONMENT_NAME= (auto)            # e.g. "production"
RAILWAY_DEPLOYMENT_ID   = (auto)            # short id of current deploy
RAILWAY_REPLICA_ID      = (auto)            # short id of this replica

Node.js example (no dependencies — fetch is built-in)

javascript
// epok.js — drop-in logger that batches + ships to Epok.
const ENDPOINT = 'https://ingest.getepok.dev/insert/elasticsearch/_bulk';
const API_KEY = process.env.EPOK_API_KEY;

const queue = [];
let timer = null;

function flush() {
  if (queue.length === 0) return;
  const batch = queue.splice(0, queue.length);
  const body = batch
    .flatMap((e) => [
      JSON.stringify({ create: {} }),
      JSON.stringify({
        _msg: e.msg,
        _time: e.time,
        level: e.level || 'info',
        service: process.env.RAILWAY_SERVICE_NAME || 'unknown',
        env: process.env.RAILWAY_ENVIRONMENT_NAME || 'production',
        deploy: process.env.RAILWAY_DEPLOYMENT_ID,
        replica: process.env.RAILWAY_REPLICA_ID,
        ...e.fields,
      }),
    ])
    .join('\n') + '\n';

  fetch(ENDPOINT, {
    method: 'POST',
    headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
    body,
  }).catch(() => {}); // never let logging break the request path
}

// Flush every 2s OR once 100 entries are queued — whichever is first.
function schedule() {
  if (timer) return;
  timer = setTimeout(() => { timer = null; flush(); }, 2000);
}

export function log(level, msg, fields = {}) {
  queue.push({ time: new Date().toISOString(), level, msg, fields });
  if (queue.length >= 100) flush();
  else schedule();
}

// Flush on process exit so the last batch isn't lost.
process.on('SIGTERM', flush);
process.on('beforeExit', flush);

// Usage:
// import { log } from './epok.js';
// log('info', 'User signup completed', { user_id: 4821 });
// log('error', 'Payment gateway timeout', { gateway: 'stripe', latency_ms: 5021 });

Python example (stdlib logging handler)

python
# epok_logger.py — attach to Python's root logger; works with stdlib,
# loguru, or any structured-logging library that forwards to logging.
import json, logging, os, queue, threading, time, urllib.request

ENDPOINT = "https://ingest.getepok.dev/insert/elasticsearch/_bulk"
API_KEY = os.environ["EPOK_API_KEY"]
SERVICE = os.environ.get("RAILWAY_SERVICE_NAME", "unknown")
ENV = os.environ.get("RAILWAY_ENVIRONMENT_NAME", "production")
DEPLOY = os.environ.get("RAILWAY_DEPLOYMENT_ID", "")
REPLICA = os.environ.get("RAILWAY_REPLICA_ID", "")


class EpokHandler(logging.Handler):
    def __init__(self):
        super().__init__()
        self.q: queue.Queue = queue.Queue(maxsize=10_000)
        threading.Thread(target=self._worker, daemon=True).start()

    def emit(self, record: logging.LogRecord) -> None:
        try:
            self.q.put_nowait({
                "_msg": record.getMessage(),
                "_time": record.created * 1000,  # ms epoch
                "level": record.levelname.lower(),
                "service": SERVICE, "env": ENV,
                "deploy": DEPLOY, "replica": REPLICA,
                "logger": record.name,
            })
        except queue.Full:
            pass  # drop newest if backed up — never block the app

    def _worker(self) -> None:
        while True:
            batch = []
            try:
                batch.append(self.q.get(timeout=2))
                while len(batch) < 100:
                    batch.append(self.q.get_nowait())
            except queue.Empty:
                pass
            if not batch:
                continue
            body_lines = []
            for entry in batch:
                body_lines.append('{"create":{}}')
                body_lines.append(json.dumps(entry))
            body = ("\n".join(body_lines) + "\n").encode("utf-8")
            try:
                req = urllib.request.Request(
                    ENDPOINT,
                    data=body,
                    headers={
                        "Authorization": f"Bearer {API_KEY}",
                        "Content-Type": "application/json",
                    },
                )
                urllib.request.urlopen(req, timeout=10).read()
            except Exception:
                pass  # never raise from a log handler


# Wire into root logger
logging.getLogger().addHandler(EpokHandler())
logging.getLogger().setLevel(logging.INFO)

Path 2: OpenTelemetry SDK (vendor-portable)

Use OpenTelemetry's OTLP HTTP exporter pointed at Epok's OTLP endpoint. Best if you might switch observability vendors later — instrument once, retarget the OTLP endpoint env var, no code changes.

One caveat: OpenTelemetry SDKs don't flush on shutdown by default. Call shutdown() on the logger provider from a SIGTERM handler in your app, otherwise the last batch drops on container restart.

Service environment variables

text
# Railway service → Variables tab
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = https://ingest.getepok.dev/v1/logs
OTEL_EXPORTER_OTLP_LOGS_HEADERS  = Authorization=Bearer epk_REPLACE_ME
OTEL_SERVICE_NAME                = ${{RAILWAY_SERVICE_NAME}}
OTEL_RESOURCE_ATTRIBUTES         = deployment.environment=${{RAILWAY_ENVIRONMENT_NAME}},service.version=${{RAILWAY_DEPLOYMENT_ID}}

The Railway-injected variables (RAILWAY_SERVICE_NAMEet al.) get interpolated into the OTEL vars via Railway's reference syntax, so every log carries the correct service/env/deploy tags without app code knowing about Railway specifically.

Path 3 (advanced): Vector as a separate Railway service

If you can't modify your app at all, deploy Vector as its own Railway service that pulls logs via Railway's GraphQL API and ships them to Epok. This is more setup than Path 1 or 2 and only worth it if app-side instrumentation is off the table.

See the AWS install guide for the Vector config shape (sources + transforms + Epok elasticsearch sink). Replace the file/journal sources with a http_clientsource polling Railway's GraphQL API. The cleaner long-term play is usually Path 2 (OpenTelemetry).

Verify

  1. Trigger a request to your Railway service.
  2. Open app.getepok.dev Live Tail. Within ~10 s you should see the log line with service populated from RAILWAY_SERVICE_NAME.
  3. Open Services— every Railway service that you've instrumented appears with its own hit + error rate.

Common gotchas

  • No sidecars.Railway doesn't run a second process inside your service container. If your stack traditionally uses a Fluent Bit / Vector sidecar (Kubernetes pattern), you'll need to fold that into the app process itself or deploy it as a separate Railway service (Path 3).
  • Flush on shutdown. Both Node and Python examples handle SIGTERM by flushing the queue. If you adapt the code to your own logger, keep that signal handler — container restarts otherwise lose ~2 s of unflushed entries.
  • Stdout still works.Logging to Epok via app code doesn't replace Railway's built-in log viewer. Your console.log / printoutput still shows up in the Railway dashboard. You're adding persistence and intelligence on top, not replacing the existing path.
  • Per-replica replicas. If your Railway service runs multiple replicas, every replica has a unique RAILWAY_REPLICA_ID. Useful for tracking per-replica error rates in Epok's Services view.

Logs flowing? Wire up notification channels under Settings → Notifications so Epok pages you when a new error appears or a service goes silent.