Skip to content

Deploy

Deploying with NixOS

The repository's flake exposes a NixOS module that runs the server, creates the database and roles, and provisions the host's Grafana. Import it, enable Grafana, and declare projects.

{
  inputs.peculiar-insights.url = "github:peculiar-systems/peculiar-insights";

  outputs = { nixpkgs, peculiar-insights, ... }: {
    nixosConfigurations.host = nixpkgs.lib.nixosSystem {
      modules = [
        peculiar-insights.nixosModules.default
        {
          services.peculiar-insights = {
            enable = true;
            projects.shop.environments.prod = {
              retentionDays = 400;
              analytics.funnels.checkout = {
                steps = [ "checkout_opened" "order_placed" ];
              };
            };
          };
          services.grafana = {
            enable = true;
            settings.server.http_addr = "127.0.0.1";
          };
        }
      ];
    };
  };
}

Projects and environments

projects.<slug>.environments.<name> declares one ingest target. Slugs and environment names are lowercase ascii letters, digits and underscores.

Option Meaning
keyFile Optional path to a file holding the write-only ingest key, loaded as a systemd credential. Left unset, the service generates the key itself.
retentionDays Delete events, crashes and sessions older than this many days. null keeps everything.
rateLimit Items the environment's key may send: perMinute (default 6000) and burst (default 12000). null lifts the limit. See below.
denylist Property keys the server drops before storing.
cohorts Named cohorts, each a list of conditions, materialised as views in the reporting schema.
analytics Funnels, retention and metrics for this environment, materialised as reporting views and a Grafana dashboard in the project's own folder. See below.

A project can be declared from any module on the host, so the natural place is the product's own module, next to the service it measures. The Insights host imports the product modules and lists nothing itself.

projects.<slug>.uploadKeyFile is an optional path to the file holding the key the project's builds present to upload symbols, one per project whatever its environments. Left unset, the service generates it under its state directory, as it does ingest keys. symbols.maxUploadBytes caps a single upload, summed over its files, at 1 GiB unless set. Uploading symbols covers both.

Keys

Nothing is generated by hand. An environment without keyFile gets its key from the service at first start: a random 32-byte hex string written under the service's state directory, readable only by the service. The effective path of every key, generated or provided, is exported as services.peculiar-insights.keyFiles."<slug>/<environment>", so a product running on the same host loads it as a credential and never sees the value in its configuration:

systemd.services.shop = {
  after = [ "peculiar-insights.service" ];
  serviceConfig.LoadCredential = [
    "insights-key:${config.services.peculiar-insights.keyFiles."shop/prod"}"
  ];
};

A product built elsewhere, a phone app or a browser bundle, needs the key at build time; copy it from the host through the secret channel you already use, or provide it with keyFile from that channel in the first place.

Analytics

Funnels, retention and metrics are declared, not written. Each declaration becomes a view in the reporting schema the server installs at start and a panel in a dashboard Grafana provisions into a folder named after the project and environment.

projects.shop.environments.prod.analytics = {
  funnels.checkout = {
    steps = [
      "checkout_opened"
      { event = "payment_started"; filters.method = "card"; }
      { event = "order_placed"; label = "ordered"; }
    ];
    windowDays = 3;
  };
  retention.weekly = {
    birth = "order_placed";
    returnEvent = { event = "order_placed"; filters.channel = "web"; };
    period = "week";
    periods = 8;
  };
  metrics = {
    orders = {
      event = "order_placed";
      filters = { channel = "web"; };
    };
    revenue = {
      event = "order_placed";
      measure = { property = "total"; aggregate = "sum"; };
    };
  };
};

Wherever an event is named, it can be named with the property values it must carry: a funnel step, a retention birth or return, a metric and a cohort condition all take filters. A plain string is the event with no filters.

Declaration Options Generates
funnels.<name> steps (two to sixty, each an event name or { event, filters, label }), windowDays (default 7), lookbackDays (default 90) reporting.funnel_<slug>_<environment>_<name> over the lookback, and a bar chart with conversion stats over Grafana's time range
retention.<name> birth, returnEvent (each an event name or { event, filters }), period (day, week or month; default week), periods (default 8), lookbackDays (default 180) reporting.retention_<slug>_<environment>_<name> over the lookback, and the cohort matrix with the average curve over Grafana's time range
metrics.<name> event, filters, measure (property and aggregate: sum, average, minimum, maximum, median, p95 or p99) reporting.metric_<slug>_<environment>_<name> with daily events, people and value, and a time series over Grafana's time range

A measure aggregates a numeric property per day. Events where the property is absent or not a number still count as events and are left out of the aggregate. Without a measure the view's value is null.

Names follow the same rule as slugs. The dashboard for an environment appears in Grafana as <slug> / <environment> inside the folder Insights · <slug> / <environment>, and lists the sizes of the environment's cohorts when it has any. The eight shared dashboards stay in the "Peculiar Insights" folder.

A cohort condition is one of person_property with key and equals, did_event with event, filters, at_least and within_days, or did_not_event with event, filters and within_days:

cohorts.payers = [
  { did_event = { event = "purchase"; filters.channel = "web"; at_least = 1; within_days = 30; }; }
  { person_property = { key = "plan"; equals = "pro"; }; }
];

The view appears as reporting.cohort_<slug>_<environment>_<name> and the segmentation dashboard offers it as a variable.

Read-only outputs

Option Meaning
keyFiles The effective key path of every environment, keyed "<slug>/<environment>", generated or provided.
uploadKeyFiles The effective symbols upload key path of every project, keyed by slug, generated or provided.
configuration The configuration the service is started with, as the module renders it. Used by the module's own check.
dashboardFiles The generated dashboard JSON per environment that declares analytics or cohorts. Used by the module's own check.

Listening

Option Default Meaning
listen.host 127.0.0.1 Address to bind.
listen.port 50051 One port for gRPC, gRPC-Web and Connect.
listen.corsOrigins [ ] Browser origins allowed to call the server; empty allows any origin.
listen.tls null { certificate, key } paths to serve TLS directly, chosen by ALPN. Otherwise terminate TLS at a reverse proxy.

Rate limits

Option Default Meaning
<environment>.rateLimit 6000 a minute, 12000 at once Items the environment's key may send. null lifts the limit.
peerLimit null perMinute (600), burst (1200) and forwardedHeader: items one network peer may send, whatever its key.
peerLimit.forwardedHeader null The header a trusted reverse proxy sets to the caller's address, such as x-forwarded-for.

A call past a limit is refused with RESOURCE_EXHAUSTED and x-peculiar-retry-after-ms; the SDKs keep what they were sending and retry after that delay. Behind a reverse proxy every call arrives from the proxy's address, so a peer limit without forwardedHeader would throttle everyone together.

Monitoring

Option Default Meaning
monitoring.enable false Serve the server's own counters at /metrics in the Prometheus text format.
monitoring.host 127.0.0.1 Address the exposition binds.
monitoring.port 9464 Its port, apart from the one SDKs reach.

Database

Option Default Meaning
database.createLocally true Run PostgreSQL on this host and create the database and roles.
database.connection local socket, peer auth libpq connection string used when the database is elsewhere.
reporting.role grafana The PostgreSQL role granted read access to the reporting schema.

With the defaults the service and Grafana both authenticate over the local socket as their own OS users, so no password exists.

Grafana

Option Default Meaning
grafana.provision true Provision the datasource, the shared dashboards, the per-environment dashboards and the alert rules.
grafana.dashboards the repository's Directory holding the dashboard JSON files.
grafana.alerting the repository's Directory holding the alert rule provisioning files.

The module provisions into the host's Grafana and does not enable it: services.grafana.enable and its port are the host's decision, since the host may already run one. The datasource uid is peculiar-insights. A second datasource, peculiar-insights-manage, points at the server and carries the dashboards' triage and erase actions; the module allows posts to it with Grafana's security.actions_allow_post_url, and gives the server the address of the host's Grafana from services.grafana.settings.server so it can verify who is acting. Alert rules reference no contact point; route notifications on the source=peculiar-insights label.

Hardening

The service runs under a dynamic user with ProtectSystem=strict, ProtectHome, PrivateTmp, PrivateDevices, NoNewPrivileges, restricted address families and a system-call filter. Network exposure is the host's decision.