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, nothandle_stripe_webhook_error. - Put the ids in
metadata. The event tells you what happened, the metadata tells you which one, and searching byinvoiceIdis the difference between an answer and a scroll.
Node.js
// 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:
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.
// 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
# 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()log_event(
"jobs",
"nightly_import_failed",
level="error",
metadata={"rows": 12_400, "reason": str(error)},
)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.
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:
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 || trueThe || 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.
# 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 |