Ingesting Events
Accept Landscape POST batches on your backend and persist them.
Handle the POST
Landscape POSTs JSON to endpoint. Return 2xx. Any other status is a transport error and the client retries from its durable queue.
Runnable reference: examples/server/src/index.ts.
app.post("/events", (req, res) => {
const batch = Array.isArray(req.body?.events) ? req.body.events : [];
const context =
req.body?.context && typeof req.body.context === "object" && !Array.isArray(req.body.context)
? req.body.context
: {};
for (const event of batch) {
const merged = { ...event, properties: { ...context, ...(event.properties ?? {}) } };
// persist merged (see Persist below)
}
res.status(202).json({ accepted: batch.length });
});
Wire format
HttpTransport (packages/core/src/transport/http.ts) sends:
{ events: LandscapeEvent[], context?: EventProperties }
Static per-page metadata that is identical across the batch is hoisted into context. Merge it back on ingest:
properties: { ...context, ...event.properties }
Each event has id, name, properties, timestamp, sessionId, visitorId, and optionally userId and schemaVersion.
Point the client at the route
Same origin (proxy /api to your API). sendBeacon works. No CORS:
init({ endpoint: "/api/events" });
Split SPA and API. Lock CORS to the SPA origin:
init({ endpoint: "https://api.example.com/events" });
Delivery order: sendBeacon, then fetch({ keepalive: true }), then fetch. sendBeacon reports success when the browser queues the request. It does not read the HTTP status. Non-2xx on that path is not retried. Default HttpTransport sets only Content-Type: application/json.
Other frameworks
Parse { events, context? }, merge context, validate, persist, return 2xx.
- Nest:
@Post("events")@HttpCode(202) - Hono:
app.post("/events", async (c) => { const body = await c.req.json(); ... return c.json({ accepted }, 202) }) - Next.js App Router:
app/api/events/route.tsexportingPOST
Spring Boot
@PostMapping("/events")
@ResponseStatus(HttpStatus.ACCEPTED)
public Map<String, Integer> ingest(@RequestBody Map<String, Object> body) {
List<Map<String, Object>> batch = (List<Map<String, Object>>) body.getOrDefault("events", List.of());
Map<String, Object> context = (Map<String, Object>) body.getOrDefault("context", Map.of());
for (Map<String, Object> event : batch) {
Map<String, Object> properties = new HashMap<>(context);
properties.putAll((Map<String, Object>) event.getOrDefault("properties", Map.of()));
event.put("properties", properties);
// persist event
}
return Map.of("accepted", batch.size());
}
Flask
@app.post("/events")
def ingest():
body = request.get_json(silent=True) or {}
batch = body.get("events") if isinstance(body.get("events"), list) else []
context = body.get("context") if isinstance(body.get("context"), dict) else {}
for event in batch:
properties = {**context, **(event.get("properties") or {})}
event["properties"] = properties
# persist event
return {"accepted": len(batch)}, 202
Requirements
- Serve the route over HTTPS in production.
- Cap JSON body size. The example uses 1 MB (
express.json({ limit: "1mb" })). - Rate-limit the route. Anyone who can load the page can POST the same JSON.
- Treat
userIdand traits as untrusted client input. - Cookie, CSRF, or ingest tokens need a custom
Transport.HttpTransportdoes not add auth headers.
Persist
You own the database. Landscape does not ship storage.
Merge context into properties, then insert. Return 202 only after a successful write. Client id values repeat on retry, so ignore duplicates.
Postgres:
CREATE TABLE events (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
timestamp BIGINT NOT NULL,
session_id TEXT NOT NULL,
visitor_id TEXT NOT NULL,
user_id TEXT,
schema_version TEXT,
properties JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX events_timestamp_desc ON events (timestamp DESC);
CREATE INDEX events_name_timestamp_desc ON events (name, timestamp DESC);
CREATE INDEX events_visitor_timestamp_desc ON events (visitor_id, timestamp DESC);
INSERT INTO events (id, name, timestamp, session_id, visitor_id, user_id, schema_version, properties)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
ON CONFLICT (id) DO NOTHING;
Query
Prefer small aggregates: counts by name in a time window, $pageview by $path, distinct visitor_id.
Top paths:
SELECT properties->>'$path' AS path, count(*) AS views
FROM events
WHERE name = '$pageview'
AND timestamp >= $1
GROUP BY 1
ORDER BY views DESC
LIMIT 20;
Display
Fetch aggregates on the server. Use the client only for charts or filters. A first screen: pageviews over time, top paths, recent $click tags, a capped recent-events table.
export default async function UsagePage() {
const res = await fetch(`${process.env.API_ORIGIN}/stats/paths`, { cache: "no-store" });
const { paths } = (await res.json()) as { paths: { path: string; views: number }[] };
if (!paths.length) return <p>No pageviews yet.</p>;
return (
<table>
<tbody>
{paths.map((row) => (
<tr key={row.path}>
<td>{row.path}</td>
<td>{row.views}</td>
</tr>
))}
</tbody>
</table>
);
}
endpoint and transport: init.