API and Examples

Instrumentation Recipes

The Logga client written by hand, per stack, plus what is worth tracking and what is not.

Every recipe here is the same twenty lines: one function that posts an event and cannot break its caller. If you would rather have an agent write it against your codebase, use Add Logga With Your AI Agent.

What to track

The useful test is whether the event would change what you do. A signup, a payment, a job that failed, an export that finally finished: those are worth a push at 3am or a line in tomorrow's report. A page view is not.

Track Skip
Money moving: subscription started, invoice paid, refund issued Every HTTP request
Lifecycle: user signed up, account deleted, plan changed Debug traces and anything your logger already has
Failures a human must act on: payment declined, webhook rejected, job dead-lettered Expected validation errors on a form
Long work finishing: nightly build, import, export One event per row inside a loop

Two more rules that keep the data readable a month later:

  • Name the event for what happened, not for the code that ran. payment_failed, not handle_stripe_webhook_error.
  • Put the ids in metadata. The event tells you what happened, the metadata tells you which one, and searching by invoiceId is the difference between an answer and a scroll.

Node.js

javascript
// logga.js
const ENDPOINT = "https://api.logga.sh/v1/events"

export async function logEvent(channel, event, options = {}) {
  const key = process.env.LOGGA_API_KEY
  if (!key) return // no key, no tracking: local runs and CI stay silent

  try {
    await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ channel, event, ...options }),
      signal: AbortSignal.timeout(2000),
    })
  } catch {
    // Tracking never breaks the thing it tracks.
  }
}

Call it without awaiting when the caller is on a request path:

javascript
app.post("/webhooks/stripe", async (req, res) => {
  const invoice = await handleStripeEvent(req.body)
  res.sendStatus(200)

  void logEvent("billing", "invoice_paid", {
    level: "success",
    metadata: { invoiceId: invoice.id, amount: invoice.total / 100 },
    actor: { id: invoice.customerId, email: invoice.customerEmail },
    idempotencyKey: `stripe_${req.body.id}`,
  })
})

The idempotencyKey is what makes this safe when Stripe redelivers the same webhook, which it will.

Next.js

Keep the key server-side. A route handler or a server action can call the module above directly; a client component cannot, and should post to your own route instead.

typescript
// app/api/checkout/route.ts
import { logEvent } from "@/lib/logga"

export async function POST(request: Request) {
  const order = await createOrder(await request.json())

  void logEvent("orders", "order_created", {
    level: "success",
    metadata: { orderId: order.id, total: order.total, currency: order.currency },
    actor: { id: order.userId, email: order.userEmail },
  })

  return Response.json({ id: order.id })
}

Python

python
# logga.py
import os
import threading
import urllib.request
import json

ENDPOINT = "https://api.logga.sh/v1/events"

def log_event(channel: str, event: str, **fields) -> None:
    key = os.environ.get("LOGGA_API_KEY")
    if not key:
        return

    payload = json.dumps({"channel": channel, "event": event, **fields}).encode()
    request = urllib.request.Request(
        ENDPOINT,
        data=payload,
        headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
    )

    def send() -> None:
        try:
            urllib.request.urlopen(request, timeout=2).read()
        except Exception:
            pass  # tracking never breaks the thing it tracks

    threading.Thread(target=send, daemon=True).start()
python
log_event(
    "jobs",
    "nightly_import_failed",
    level="error",
    metadata={"rows": 12_400, "reason": str(error)},
)

Go

go
// logga/logga.go
package logga

import (
    "bytes"
    "encoding/json"
    "net/http"
    "os"
    "time"
)

var client = &http.Client{Timeout: 2 * time.Second}

func LogEvent(payload map[string]any) {
    key := os.Getenv("LOGGA_API_KEY")
    if key == "" {
        return
    }

    go func() {
        body, err := json.Marshal(payload)
        if err != nil {
            return
        }

        request, err := http.NewRequest("POST", "https://api.logga.sh/v1/events", bytes.NewReader(body))
        if err != nil {
            return
        }
        request.Header.Set("Authorization", "Bearer "+key)
        request.Header.Set("Content-Type", "application/json")

        response, err := client.Do(request)
        if err == nil {
            response.Body.Close()
        }
    }()
}

Swift

An API key inside a shipped app is readable by anyone who downloads it, so create the key bound to that one project and let the scopes default to write-only. See Keys for a distributed client.

swift
enum Logga {
    private static let endpoint = URL(string: "https://api.logga.sh/v1/events")!

    static func log(channel: String, event: String, level: String = "info", metadata: [String: Any] = [:]) {
        guard let key = Bundle.main.object(forInfoDictionaryKey: "LOGGA_API_KEY") as? String,
              !key.isEmpty else { return }

        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.timeoutInterval = 5
        request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: [
            "channel": channel,
            "event": event,
            "level": level,
            "metadata": metadata,
        ])

        URLSession.shared.dataTask(with: request).resume()
    }
}

Shell and CI

No client needed. This is the whole integration for a deploy pipeline:

bash
curl -sS -X POST https://api.logga.sh/v1/events \
  -H "Authorization: Bearer $LOGGA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"channel\": \"deploys\",
    \"event\": \"deploy_finished\",
    \"level\": \"success\",
    \"metadata\": { \"commit\": \"$GITHUB_SHA\", \"branch\": \"$GITHUB_REF_NAME\" }
  }" > /dev/null || true

The || true matters: a failed deploy notification should not fail the deploy.

Long jobs: use a session

An import that runs for twenty minutes is not one event, and it is not four hundred. It is a session: one record you open, update as it progresses, and close.

bash
# Open it
curl -X POST https://api.logga.sh/v1/sessions \
  -H "Authorization: Bearer $LOGGA_API_KEY" -H "Content-Type: application/json" \
  -d '{ "channel": "jobs", "name": "nightly_import", "ttlSeconds": 3600 }'

# Update it as it goes
curl -X PATCH https://api.logga.sh/v1/sessions/$SESSION_ID \
  -H "Authorization: Bearer $LOGGA_API_KEY" -H "Content-Type: application/json" \
  -d '{ "currentStep": "importing invoices", "progress": 60 }'

# Finish it
curl -X POST https://api.logga.sh/v1/sessions/$SESSION_ID/complete \
  -H "Authorization: Bearer $LOGGA_API_KEY" -H "Content-Type: application/json" \
  -d '{ "summary": "12400 rows in 18m" }'

A session that is never closed and outlives its ttlSeconds is how you find out a job died without anybody noticing.

Errors you will meet

Status Meaning Fix
401 Key missing, revoked, or expired Check the Authorization header carries Bearer lg_sk_...
403 insufficient_scope The key lacks the scope the route needs The response names the missing scope. Create a key that has it.
403 project_forbidden The key is bound to one project and you named another Drop the ?projectId=, or use the right key
422 validation_error The body failed validation The response names the field. Usually a missing channel or event, or metadata over 10KB.
429 rate_limit_exceeded Over 1000 events per 15 minutes on that key Batch, sample, or stop tracking things that are not worth an event