Preskoči na sadržaj

Inženjering11 min čitanja

Kako smo napravili sopstvene događaje u appmetrica.online, analitici bez SDK-a

Sopstveni događaji sa svojstvima u našoj otvorenoj self-hosted analitici: ugovor, zašto se vrednosti čuvaju kao stringovi, aditivna ClickHouse migracija, ljudi po vrednosti svojstva i greške koje smo ispravili usput.

appmetrica.online je naša otvorena analitika za aplikacije, sajtove i botove koja radi bez SDK-a: šaljete obične HTTP zahteve, a ostalo radi server. Može da se postavi na sopstveni server, licenca je AGPL-3.0, a kod je na GitHub-u. Alat ima svoju stranicu projekta na ovom sajtu, a ruska verzija servisa je na adresi appmetrica.online/ru.

Commit-i za sopstvene događaje datirani su 3. novembra 2026, a ovaj tekst je napisan 4. novembra. Oslanja se na kod, istoriju gita i javnu stranicu dokumentacije. Istorija prati redosled „prvo test” iz našeg članka o TDD-u: ispred svakog feat(...) ide commit test(...).

Zašto sopstveni događaji i zašto i dalje bez SDK-a

Proizvod je počeo samo sa sesijama. Klijent šalje session_start i session_end na jednu tačku, POST /v1/events, u paketima do 500 događaja, idempotentno po insert_id, uz offline red do 7 dana. Bez SDK-a, recept za svaku platformu je nekoliko linija HTTP-a, pa je ugovor zapravo proizvod, a sve novo mora biti aditivno: klijent koji šalje samo sesije ne sme da se promeni.

Iz naših beleški o dizajnu vidi se da su sopstveni događaji odloženi dok se ne pojavi pitanje na koje sesije ne mogu da odgovore. Pojavilo se: levci naših sopstvenih aplikacija (onboarding, prvi rezultat, paywall, kupovina) živeli su u drugim analitičkim servisima, a hteli smo da ih vidimo kod sebe. Prvo pitanje bilo je koliko ljudi iz datog izvora stiže do prvog rezultata, a to je jedan događaj plus raspodela po svojstvu. Zato sopstveni događaji dele sa sesijama tabelu, tačku prijema i paket, a dodaju samo sopstveno ime i neobavezan objekat properties.

Ugovor

Ime mora da odgovara obrascu ^[a-z][a-z0-9_]{0,63}$: mala latinična slova, cifre i donje crte, do 64 znaka. Prefiks session_ rezervisan je za sve osim session_start i session_end. Razlog navodi dokumentacija: tako se kasnije mogu dodavati sistemski događaji bez sudara sa imenima koja neko već koristi.

properties je neobavezan objekat sa ovim pravilima:

  • do 50 ključeva, svaki po istom obrascu kao ime događaja;
  • vrednost je string do 256 znakova, broj ili boolean;
  • null, niz ili objekat kao vrednost se odbijaju, a "properties": null tretira se kao da polja nema.

session_id je i dalje obavezan za svaki događaj. Naše tumačenje beleški: profil i ugovor izgrađeni su oko sesija, a događaji van sesije (bekendi, botovi) ostaju odluka za kasnije.

Loše ime se i ranije odbijalo kodom unknown_event_name i ponovo smo ga upotrebili. Klijenti koji grane po tom kodu tretiraju ga kao odbijanje bez ponavljanja, a novi kod bi im pokvario to grananje. Promenio se samo tekst poruke: sada obuhvata ime van obrasca ili rezervisano ime. PII je nova situacija, pa je dobila novi kod.

PrekršajKod
Ime van obrasca ili sa prefiksom session_unknown_event_name
Nije objekat, više od 50 ključeva, loš ključ, pogrešan tip vrednosti, string duži od 256 znakova ili sa U+0000invalid_field
String liči na email ili telefonproperty_looks_like_pii

Svako odbijanje je bez ponavljanja i odnosi se samo na svoj događaj. Ostatak paketa se prihvata.

Normalizacija i privatnost

Vrednosti se čuvaju kao stringovi. Izveštaji razlažu događaj po tekstu vrednosti, pa im je potreban samo tekst, a numeričkih agregata u prvoj verziji nema. Stringovi se čuvaju onakvi kakvi jesu, bez trim-a. Boolean postaje true ili false. Broj dobija najkraći decimalni zapis: 12.5 ostaje 12.5, a -0 postaje 0, da ne bi činio poseban red raspodele. JSON brojevi se čitaju kao 64-bitni float, pa celi brojevi veći od 2^53 gube preciznost, a dokumentacija savetuje da se takvi šalju kao stringovi.

Svojstva se proveravaju ključ po ključ u redosledu dokumenta, a prvi prekršaj odbija ceo događaj. Kod čita objekat kao tok tokena, a ne u Go map, jer je redosled obilaska mape slučajan i prijavljeni prekršaj bi se menjao od pokretanja do pokretanja. Provera vrednosti u skraćenom obliku:

properties.gogo
const (
	// MaxProperties je maksimalan broj ključeva u properties događaja.
	MaxProperties = 50
	// MaxPropertyValueLength je maksimalan broj znakova (rune) string vrednosti svojstva.
	MaxPropertyValueLength = 256
)

// propertyKeyRe je obrazac ključa. U ugovoru je opisan samo tekstom,
// jer OpenAPI 3.0 nema 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 + " mora biti string, broj ili boolean.")
		}
		switch {
		case utf8.RuneCountInString(s) > MaxPropertyValueLength:
			return invalid(field + " je duže od 256 znakova.")
		case strings.ContainsRune(s, 0):
			return invalid(field + " sadrži znak U+0000.")
		case LooksLikePII(s):
			return "", reject(index, insertID, ingestapi.RejectionCodePropertyLooksLikePii, field,
				field+" liči na email ili telefon. Ne prosleđujte lične podatke u svojstvima.")
		}
		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 + ": broj van dozvoljenog opsega.")
		}
		if v == 0 {
			return "0", nil // -0 i 0 su ista vrednost za raspodelu
		}
		return strconv.FormatFloat(v, 'f', -1, 64), nil
	// ...
	}
}

Poruke u repozitorijumu su na ruskom; ovde smo ih preveli zajedno sa komentarima.

PII zaštita je ista provera LooksLikePII koja štiti user_id. Ako string liči na email adresu ili telefon, ceo događaj se odbija kodom property_looks_like_pii. Alternativa je bila da se svojstvo odbaci, a događaj prihvati. Mi odbijamo, jer tiho odbacivanje krije curenje od programera klijenta, dok se odbijanje odmah vidi na ekranu integracije. Zaštita hvata samo očigledne formate, što i dokumentacija priznaje, pa zato savetuje neprozirne ID-ove i kategorije. Za odbijene događaje čuva se uzorak tela zahteva radi otklanjanja grešaka, a sumnjiva vrednost pod properties tamo je zamenjena sa [redacted].

Skladište

Svojstva žive u jednoj novoj koloni postojeće tabele events, a migracija je čisto aditivna:

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) ne traži eksperimentalna podešavanja, a kolona se dodaje poslednja i menja samo metapodatke. Skip indeksa nema, jer izveštaji filtriraju po app_id, event_date i name. Nema ni dnevnih agregata za događaje: izveštaji čitaju sirove događaje, jer su količine male, a preračunavanje zatvorenih dana posle promene vremenske zone, brisanja ili spajanja aplikacija najkrhkiji je deo našeg workera. Agregate ćemo dodati kad zatreba brzina.

Zamka povratka

Nova kolona kvari prethodni image. Stari upis bio je goli INSERT INTO events, a AppendStruct u Go drajveru za ClickHouse traži polje strukture za svaku kolonu tabele. Posle ADD COLUMN stari image ne može da piše. Naš integracioni test reprodukuje grešku missing destination name "properties". Novi upis eksplicitno navodi kolone, da sledeća kolona ne pokvari povratak:

insert.gogo
// eventColumns je eksplicitna lista kolona INSERT-a u redosledu polja EventRow.
// Bez liste AppendStruct traži polje za svaku kolonu tabele, a image prethodnog
// izdanja prestaje da piše posle ADD COLUMN.
const eventColumns = ` (app_id, event_date, event_time, /* ... */ write_key_id, properties)`

Spajanje aplikacija kopira događaje po sopstvenim listama kolona, pa novi integracioni test poredi system.columns sa obe liste. Zaboravljena kolona obara test, umesto da tiho nestane.

Izveštaji

Izveštaji broje događaje kao uniqExact(insert_id), a ne count(). Tabela events je ReplacingMergeTree, koja spaja duplikate tek u pozadinskom merge-u. Klijent koji ponovi paket do tada ostavlja isti insert_id u više redova, a count() bi prebrojao svaku kopiju. Test linterom proverava sav SQL izveštaja: pod aliasima events i prev_events sve osim uniqExact(insert_id) ne prolazi.

Ljudi se broje kao u izveštajima publike: jedna osoba po user_id ako postoji, inače po instalaciji. Red svake osobe nosi vrednosti njenih događaja, a ARRAY JOIN ga razlaže po njima. Raspodela ima tri vrste redova: vrh vrednosti (podrazumevano 20, najviše 100), jedan red ostalo za ostale vrednosti i red nema svojstva za događaje bez svojstva. Par (ključ postoji, vrednost) razlikuje praznu vrednost od odsutnog ključa. SQL u skraćenom obliku:

events.gosql
-- people: red po osobi sa groupUniqArray parova (postoji li ključ, vrednost)
-- njenih događaja (gradi ga peopleWhereCTE, ovde skraćeno)
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), a ne count(): pre merge-a su mogući duplikati
    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,
       -- ljudi reda other su unija stanja, a ne zbir
       uniqExactMerge(people_state) AS people
FROM ranked
GROUP BY kind, label
ORDER BY events DESC, label ASC

Događaji reda ostalo su običan zbir, jer događaj ima jednu vrednost ključa i skupovi se ne preklapaju. Sa ljudima je drugačije: jedna osoba može imati događaje sa više vrednosti. Ako je neko pravio beleške glasom i preko „podeli”, a obe vrednosti upadnu u ostalo, ta osoba se broji jednom. Zato upit čuva uniqExactState po svakoj vrednosti i spaja stanja pomoću uniqExactMerge; zbir ljudi po vrednostima brojao bi je dvaput.

Šta nije išlo po planu

Prvi plan povratka bio je pogrešan. Kad smo našli zamku, u beleškama smo zapisali povratak komandom appmetrica migrate naniže do verzije 3. Komanda ima samo up i status, a down smo odlučili da ne dodajemo. Commit 931a970 prepisao je runbook kao ručnu proceduru: zaustaviti ingest i worker, ukloniti kolonu, obrisati red verzije 4 iz goose tabele, pa postaviti prethodni image. Taj red je bitan. Bez njegovog brisanja novo postavljanje bi migraciju 4 smatralo primenjenom i ne bi vratilo kolonu. Naknadni test izvršava te naredbe na pravom ClickHouse-u i potvrđuje obe tvrdnje.

Upozorenje

Povratak iza ovog izdanja briše sva svojstva događaja. Događaji i imena ostaju, vrednosti nestaju. Prvo napravite svež bekap.

Maskiranje uzoraka tela zahteva imalo je rupe. Prva verzija je ključ properties poredila uz razlikovanje velikih i malih slova. Jedna ispravka učinila je poređenje neosetljivim na velika i mala slova. Druga pokrila je ugnežđene objekte, nizove i vrednosti koje nisu objekti pod properties. Validator takve oblike odbija, ali se uzorak odbijenog događaja čuva 7 dana, a po našem tumačenju u njemu ne sme biti sirovog emaila.

Lista događaja gubila je imena koja su pala na nulu. Upit liste filtrirao je po events > 0. Uz uključeno poređenje, događaj koji je bio u prošlom periodu, a sada ga nema, nestajao je umesto da se prikaže kao 0 i −100%. Ispravka zadržava ime ako ima događaja u bar jednom periodu. Slična izmena naterala je profil osobe da prikazuje događaje svih okruženja sa oznakom development, a ne samo production (commit).

Kako se koristi

Paket sa sesijom i događajem purchase, kao u javnoj dokumentaciji:

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

U dashboardu kartica Events prikazuje sopstvene događaje: broj, ljude i promenu u odnosu na prethodni period. Stranica događaja ima grafikon, zbirove i raspodelu po izabranom svojstvu. Profil osobe prikazuje njene događaje, a realtime tok pod njihovim sopstvenim imenima. Read API ima četiri operacije: listEvents, getEventReport, getEventBreakdown i listProfileEvents. Da biste servis pokrenuli kod sebe, pratite korake self-hostinga iz README-a repozitorijuma: Docker Compose ili Dokploy, ClickHouse za događaje i PostgreSQL za metapodatke.

Znajte ograničenja. Izveštaji broje događaje i ljude po vrednostima i ne sabiraju brojeve, pa se amount vidi kao raspodela po vrednostima, a ne kao zbir prihoda. Levke, segmente po svojstvu i događaje bez sesije naše beleške takođe svrstavaju u zasebne odluke za kasnije. I na kraju, postavite server pre verzije aplikacije koja šalje događaje: stariji ingest će ih odbiti kao unknown_event_name i izgubiće se.

Šta uraditi

  • Šaljite sopstvene događaje unutar sesije, sa istim session_id kao session_start.
  • Koristite stabilna imena u snake_case obliku, koja ne počinju sa session_. Preimenovan događaj počinje novu seriju.
  • Vrednosti stavljajte u svojstva, a ne u imena. opened_note_4821 je ime sa ugrađenom vrednošću.
  • Držite tip svojstva stabilnim između izdanja; ogromne cele brojeve šaljite kao stringove.
  • Ne prosleđujte email adrese, telefone i imena u svojstvima. Koristite neprozirne ID-ove i kategorije.
  • Kad dodajete kolonu u tabelu događaja, navedite kolone eksplicitno u upisu i unapred zapišite povratak.
  • Događaje brojte po ključu sa deduplikacijom, a ljude kao uniju, ne kao zbir.

Izvori

PodeliXLinkedIn

ONLYAPPS Team

Novi Sad, Srbija · onlyapps.org

Kontakt

Kolačiće za analitiku koristimo samo uz vašu saglasnost. Neophodni kolačići održavaju rad sajta. Politika privatnosti