Skip to content

Engineering11 min read

How we built custom events in appmetrica.online, an analytics tool with no SDK

Custom events with properties in our open-source, self-hosted analytics: the contract, why values are stored as strings, an additive ClickHouse migration, people counts per property value and the bugs we fixed along the way.

appmetrica.online is our open-source analytics for apps, sites and bots that works without an SDK: you send plain HTTP requests, and the server does the rest. It is self-hosted, licensed AGPL-3.0, and the code is on GitHub. The tool has its own project page on this site, and a Russian version lives at appmetrica.online/ru.

The commits for custom events are dated November 3, 2026, and this write-up is from November 4. It draws on the code, the git history and the public documentation page. The history follows the test-first order from our TDD article: a test(...) commit lands before each feat(...).

Why custom events, and why still no SDK

The product started with sessions only. A client sends session_start and session_end to one endpoint, POST /v1/events, in batches of up to 500, idempotent by insert_id, with an offline queue of up to 7 days. Without an SDK, every platform recipe is a few lines of HTTP, so the contract is the product, and anything new has to be additive: a client that only sends sessions must not change.

Our design notes show that custom events were postponed until there was a question sessions could not answer. One appeared: the funnels of our own apps (onboarding, first result, paywall, purchase) lived in other analytics tools, and we wanted them in ours. The first question was how many people from a given source reached a first result, which is one event plus a breakdown by a property. Custom events therefore share the table, the endpoint and the batch with sessions, and add only a name of your own and an optional properties object.

The contract

A name must match ^[a-z][a-z0-9_]{0,63}$: lowercase Latin letters, digits and underscores, up to 64 characters. The prefix session_ is reserved for everything except session_start and session_end. The docs give the reason: new system events can be added later without colliding with names someone already uses.

properties is an optional object with these rules:

  • up to 50 keys, each matching the same pattern as event names;
  • a value is a string up to 256 characters, a number or a boolean;
  • null, an array or an object as a value is rejected, while "properties": null is treated as absent.

session_id stays required for every event. Our reading of the design notes: the profile page and the contract are built around sessions, and events outside a session (backends, bots) are a decision for later.

A bad name was already rejected as unknown_event_name, and we reused that code. Clients that branch on it treat it as non-retryable, and a new code would have broken that branching. Only the message changed: it now covers a name that is off-pattern or reserved. PII is a new situation, so it got a new code.

ViolationCode
Name off-pattern or session_-prefixedunknown_event_name
Not an object, more than 50 keys, bad key, bad value type, string over 256 characters or with U+0000invalid_field
String that looks like an email or a phone numberproperty_looks_like_pii

Every rejection is non-retryable and affects only that event. The rest of the batch is accepted.

Normalisation and privacy

Values are stored as strings. Reports break an event down by the text of a value, so text is all they need, and the first version has no numeric aggregates. Strings are kept as is, without trimming. Booleans become true or false. Numbers get the shortest decimal form, so 12.5 stays 12.5 and -0 becomes 0, which keeps it from forming a separate breakdown row. JSON numbers are read as 64-bit floats, so integers above 2^53 lose precision, and the docs say to send those as strings.

Properties are checked key by key in document order, and the first violation rejects the whole event. The code reads the object as a token stream rather than into a Go map, because map iteration order is random and the reported violation would differ between runs. The trimmed value check:

properties.gogo
const (
	// MaxProperties is the maximum number of keys in an event's properties.
	MaxProperties = 50
	// MaxPropertyValueLength is the maximum length of a string value, in characters (runes).
	MaxPropertyValueLength = 256
)

// propertyKeyRe is the key pattern. The contract only describes it in text,
// because OpenAPI 3.0 has no propertyNames.
var propertyKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)

// ...

func propertyValue(index int, insertID string, p propertyPair) (string, *ingestapi.Rejection) {
	// ...
	switch first := p.raw[0]; {
	case first == '"':
		var s string
		if json.Unmarshal(p.raw, &s) != nil {
			return invalid(field + " must be a string, a number or a boolean.")
		}
		switch {
		case utf8.RuneCountInString(s) > MaxPropertyValueLength:
			return invalid(field + " is longer than 256 characters.")
		case strings.ContainsRune(s, 0):
			return invalid(field + " contains U+0000.")
		case LooksLikePII(s):
			return "", reject(index, insertID, ingestapi.RejectionCodePropertyLooksLikePii, field,
				field+" looks like an email or a phone number. Do not pass personal data in properties.")
		}
		return s, nil
	// ...
	case first == '-' || (first >= '0' && first <= '9'):
		v, err := strconv.ParseFloat(string(p.raw), 64)
		if err != nil || math.IsInf(v, 0) {
			return invalid(field + ": number out of range.")
		}
		if v == 0 {
			return "0", nil // -0 and 0 are one value for the breakdown
		}
		return strconv.FormatFloat(v, 'f', -1, 64), nil
	// ...
	}
}

The messages in the repository are in Russian; we translated them together with the comments.

The PII guard is the same LooksLikePII check that protects user_id. If a string looks like an email address or a phone number, the whole event is rejected with property_looks_like_pii. The alternative was to drop the property and accept the event. We reject, because silent dropping hides the leak from the client developer, while a rejection shows up in the integration view at once. The guard catches only obvious formats, as the docs admit, so they push opaque IDs and categories. Rejected events keep a payload sample for debugging, and a suspicious value under properties is replaced with [redacted] there.

Storage

Properties live in one new column of the existing events table, and the migration is purely additive:

00004_event_properties.sqlsql
-- +goose NO TRANSACTION
-- +goose Up
-- +goose StatementBegin
ALTER TABLE events ADD COLUMN IF NOT EXISTS properties Map(LowCardinality(String), String);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
ALTER TABLE events DROP COLUMN IF EXISTS properties;
-- +goose StatementEnd

Map(LowCardinality(String), String) needs no experimental settings, and the column is added last as a metadata-only change. There are no skip indexes, because reports filter by app_id, event_date and name. Nor are there daily aggregates for events: reports read raw events, since volumes are small and recomputing closed days after a time zone change, a deletion or an app merge is the most fragile part of our worker. We will add aggregates when we need the speed.

The rollback trap

The new column breaks the previous image. The old insert was a bare INSERT INTO events, and AppendStruct in the ClickHouse Go driver wants a struct field for every table column. After ADD COLUMN, the old image cannot write. Our integration test reproduces the error missing destination name "properties". The new insert lists its columns explicitly, so the next column cannot break a rollback:

insert.gogo
// eventColumns is the explicit column list of the INSERT, in the order of the EventRow fields.
// Without a list, AppendStruct requires a field for every table column, and the previous
// release image stops writing after ADD COLUMN.
const eventColumns = ` (app_id, event_date, event_time, /* ... */ write_key_id, properties)`

App merging copies events with its own column lists, so a new integration test compares system.columns with both lists. A forgotten column fails a test instead of being lost silently.

Reports

Reports count events as uniqExact(insert_id), not count(). The events table is a ReplacingMergeTree, which collapses duplicates only in a background merge. A client that retries a batch leaves the same insert_id in several rows until then, and count() would count every copy. A test lints all report SQL: under the aliases events and prev_events, anything but uniqExact(insert_id) fails.

People are counted as the audience reports count them: one person per user_id when present, otherwise per install. Each person's row carries the values of their events, and ARRAY JOIN spreads it over them. The breakdown has three kinds of rows: the top values (20 by default, at most 100), one other row for the rest, and a missing row for events without the property. The pair (key present, value) tells an empty value from an absent key. The SQL, trimmed:

events.gosql
-- people: one row per person with groupUniqArray of the pairs (has the key, value)
-- of their events (built by peopleWhereCTE, trimmed here)
by_people AS (
    SELECT tupleElement(v, 1) AS has, tupleElement(v, 2) AS value, uniqExactState(person_key) AS people_state
    FROM people ARRAY JOIN vals AS v
    GROUP BY has, value
),
by_events AS (
    -- uniqExact(insert_id), not count(): duplicates are possible before a merge
    SELECT mapContains(e.properties, {property:String}) AS has, e.properties[{property:String}] AS value,
           uniqExact(e.insert_id) AS events
    FROM events AS e
    WHERE ...
    GROUP BY has, value
),
ranked AS (
    SELECT v.has AS has, v.value AS value, v.events AS value_events, p.people_state AS people_state,
           row_number() OVER (PARTITION BY v.has ORDER BY v.events DESC, v.value ASC) AS pos
    FROM by_events AS v
    INNER JOIN by_people AS p ON p.has = v.has AND p.value = v.value
)
SELECT multiIf(NOT has, 'missing', pos <= {limit:UInt32}, 'value', 'other') AS kind,
       if(kind = 'value', value, '') AS label,
       sum(value_events) AS events,
       -- the people of other are a union of states, not a sum
       uniqExactMerge(people_state) AS people
FROM ranked
GROUP BY kind, label
ORDER BY events DESC, label ASC

The events of other are a plain sum, because an event has one value of a key and the sets do not overlap. People differ: one person can have events with several values. If someone created notes by voice and by share, and both values fall into other, that person counts once. So the query keeps uniqExactState per value and merges the states with uniqExactMerge; summing per-value people would count them twice.

What did not go as planned

The rollback plan was wrong the first time. After finding the trap, our notes said to roll back with appmetrica migrate down to version 3. The command has only up and status, and we decided not to add down. Commit 931a970 rewrote the runbook as a manual procedure: stop ingest and the worker, drop the column, delete the version 4 row from the goose table, then deploy the previous image. The row matters. Without deleting it, a redeploy would consider migration 4 applied and never bring the column back. A follow-up test runs these statements on a real ClickHouse and proves both claims.

Warning

A rollback past this release drops all event properties. Events and names remain, but the values are gone. Take a fresh backup first.

Redaction of payload samples had gaps. Our first version matched the properties key by exact case. One fix made it case-insensitive. Another covered nested objects, arrays and non-object values under properties. The validator rejects such shapes, but a rejected event's sample is stored for 7 days, and in our reading it should never contain a raw email.

The events list lost names that fell to zero. The list query filtered on events > 0. With the comparison on, an event that had occurrences in the previous period and none now vanished instead of showing 0 and −100%. The fix keeps a name if either period has events. A similar correction made a person's profile show events of all environments, with a development badge, instead of production only (commit).

Using it

A batch with a session and a purchase event, as in the public docs:

bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
INSTALL=$(uuidgen | tr A-Z a-z)
SESSION=$(uuidgen | tr A-Z a-z)
curl -sS https://i.analytics.example.com/v1/events \
  -H "Content-Type: application/json" \
  -H "X-App-Key: $AM_PK" \
  --data-binary @- <<JSON
{
  "sent_at": "$NOW",
  "context": { "platform": "server", "environment": "development", "ip": "none" },
  "events": [
    {
      "insert_id": "$(uuidgen | tr A-Z a-z)",
      "name": "session_start",
      "timestamp": "$NOW",
      "install_id": "$INSTALL",
      "session_id": "$SESSION"
    },
    {
      "insert_id": "$(uuidgen | tr A-Z a-z)",
      "name": "purchase",
      "timestamp": "$NOW",
      "install_id": "$INSTALL",
      "session_id": "$SESSION",
      "properties": { "plan": "pro", "amount": 12.5, "trial": false }
    },
    {
      "insert_id": "$(uuidgen | tr A-Z a-z)",
      "name": "session_end",
      "timestamp": "$NOW",
      "install_id": "$INSTALL",
      "session_id": "$SESSION",
      "duration_ms": 4200
    }
  ]
}
JSON

In the dashboard, the Events tab lists custom events with events, people and the change against the previous period. An event page has a chart, totals and a breakdown by a property you pick. The profile page shows a person's events, and the realtime feed shows them under their own names. The Read API has four operations: listEvents, getEventReport, getEventBreakdown and listProfileEvents. To run it yourself, follow the self-hosting steps in the repository README: Docker Compose or Dokploy, ClickHouse for events and PostgreSQL for metadata.

Know the limits. Reports count events and people per value and do not add numbers up, so amount appears as a breakdown by value, not as a revenue total. Our design notes also list funnels, segments by property and events without a session as separate later decisions. Finally, deploy the server before the app build that sends events: an older ingest rejects them as unknown_event_name, and they are lost.

What to do

  • Send custom events inside a session, with the same session_id as session_start.
  • Use stable snake_case names, never starting with session_. A renamed event starts a new series.
  • Put values into properties, not into names. opened_note_4821 is a name with a value baked in.
  • Keep a property's type stable across releases; send huge integers as strings.
  • Keep emails, phone numbers and names out of properties. Use opaque IDs and categories.
  • If you add a column to an events table, list columns explicitly in the insert and write down the rollback.
  • Count events with a deduplicating key, and count people as a union, not a sum.

Sources

ShareXLinkedIn

ONLYAPPS Team

Novi Sad, Serbia · onlyapps.org

Contact

We use analytics cookies only with your consent. Essential cookies keep the site working. Privacy policy