LANDSCAPE

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.ts exporting POST

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 userId and traits as untrusted client input.
  • Cookie, CSRF, or ingest tokens need a custom Transport. HttpTransport does 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.