analytics: one ingest door — /v1/insights/e removed, its wire kept
A wire is a SHAPE, and a shape has never earned a path. /v1/insights/e existed
only because the PostHog wire spells fields differently; decodeIngest already
sniffs object-vs-array and bare-vs-envelope on one route, so sniffing one more
encoding is the mechanism that is already there, not a new one.
The wire does NOT go away — decodeEvent picks the decoder by sniffing keys, and
the ingress rewrite that fed the old door (insights-cloud-ingest-rewrite:
insights.hanzo.ai /e,/batch,/capture) now replacePaths onto /v1/event, so every
PostHog-wire caller keeps working. Six doors become five.
The sniff is on KEYS, not on 'did the first decoder return anything'. I wrote the
count-based fallback first and TestMount_HostCarve_IngestsForSiteOrg refuted it:
decodeIngest ACCEPTS a PostHog body as a bare canonical Event and returns ONE
event, which is then dropped whole downstream (canonicalType("") is "event",
not in publicKinds). The caller gets 200 and the event vanishes. A count of 1 is
not evidence the body was understood.
The wires are distinguishable exactly: canonical spells `distinctId` (camel) and
carries `type`; PostHog spells `distinct_id` (snake) and carries `api_key`.
Neither key appears in the other wire, so presence is proof, not a heuristic.
Batches are probed on elements because `batch` is shared.
Security properties re-proven on the one door, not assumed: key extraction from
body/query/x-api-key, presented-but-unresolvable => 403 fail-closed, keyless =>
the anonymous lane. Those tests now drive /v1/event and pass. Full package green.
$source says nothing real used the old door: 3584 rows via 'event', 15 via
'capture', 1 via 'posthog' — and that 1 is my own probe from this session.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
// These tests cover the project-API-key → org resolution on the deprecated PostHog
|
||||
// door, so keyed, bearer-less SDK traffic (posthog-js / insights-go batch) maps to a
|
||||
// tenant. They drive the REAL /v1/insights/e handler through the injectable
|
||||
// tenant. They drive the REAL /v1/event handler (PostHog wire) through the injectable
|
||||
// resolveKeyOrg seam, so no IAM is needed. The observable proxy for "resolved to a
|
||||
// tenant" is "passed the credential gate" — i.e. NOT 403; without a datastore the
|
||||
// handler then returns 503, so any non-403 status means the request was admitted.
|
||||
@@ -76,9 +76,9 @@ func TestKeyOrg_KeyExtractionReachesResolver(t *testing.T) {
|
||||
hdr map[string]string
|
||||
wantKey string
|
||||
}{
|
||||
{"body", "/v1/insights/e", `{"api_key":"hk-body","event":"e","distinct_id":"d"}`, nil, "hk-body"},
|
||||
{"query", "/v1/insights/e?api_key=hk-query", `{"event":"e","distinct_id":"d"}`, nil, "hk-query"},
|
||||
{"x-api-key", "/v1/insights/e", `{"event":"e","distinct_id":"d"}`, map[string]string{"x-api-key": "hk-hdr"}, "hk-hdr"},
|
||||
{"body", "/v1/event", `{"api_key":"hk-body","event":"e","distinct_id":"d"}`, nil, "hk-body"},
|
||||
{"query", "/v1/event?api_key=hk-query", `{"event":"e","distinct_id":"d"}`, nil, "hk-query"},
|
||||
{"x-api-key", "/v1/event", `{"event":"e","distinct_id":"d"}`, map[string]string{"x-api-key": "hk-hdr"}, "hk-hdr"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -100,7 +100,7 @@ func TestKeyOrg_KeyExtractionReachesResolver(t *testing.T) {
|
||||
func TestKeyOrg_UnresolvableKeyFailsClosed(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
stubResolver(t, func(string) (string, bool) { return "", false }) // nothing resolves
|
||||
code := postKeyed(t, app, "/v1/insights/e", "hanzo.ai",
|
||||
code := postKeyed(t, app, "/v1/event", "hanzo.ai",
|
||||
`{"api_key":"hk-bad","event":"e","distinct_id":"d"}`, nil)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("presented-but-unresolvable key on a brand host must 403 (fail closed), got %d", code)
|
||||
@@ -119,7 +119,7 @@ func TestKeyOrg_KeylessRequestNeverConsultsResolver(t *testing.T) {
|
||||
t.Fatalf("resolver consulted for a keyless request (key=%q)", key)
|
||||
return "", false
|
||||
})
|
||||
code := postKeyed(t, app, "/v1/insights/e", "hanzo.ai",
|
||||
code := postKeyed(t, app, "/v1/event", "hanzo.ai",
|
||||
`{"event":"e","distinct_id":"d"}`, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("keyless PostHog event want 200 (anonymous lane, kind dropped), got %d", code)
|
||||
|
||||
@@ -44,9 +44,15 @@ import (
|
||||
// decodeInsights and every canonical-wire beacon silently decodes to nothing, or
|
||||
// relabel a door's source and the $source column — which is the sunset signal, and
|
||||
// the only per-row record of which door a write came through — starts lying.
|
||||
// /v1/insights/e was REMOVED as a door on 2026-07-31: a wire is a shape, and a shape
|
||||
// does not earn a path. Its wire did not go away — decodeEvent tries the canonical
|
||||
// decoder and falls back to decodeInsights when canonical yields nothing — and the
|
||||
// ingress rewrite that fed it (insights-cloud-ingest-rewrite: insights.hanzo.ai
|
||||
// /e,/batch,/capture) now replacePaths onto /v1/event, so every PostHog-wire caller
|
||||
// keeps working through the one door. sourcePostHog therefore no longer appears here;
|
||||
// its 1 lifetime row is a probe, not traffic.
|
||||
var wantDoors = []door{
|
||||
{path: "/v1/event", decode: decodeIngest, source: sourceEvent},
|
||||
{path: "/v1/insights/e", decode: decodeInsights, source: sourcePostHog},
|
||||
{path: "/v1/event", decode: decodeEvent, source: sourceEvent},
|
||||
{path: "/v1/analytics", decode: decodeIngest, source: sourceCapture},
|
||||
{path: "/v1/analytics/batch", decode: decodeIngest, source: sourceCapture},
|
||||
{path: "/v1/tracker", decode: decodeIngest, source: sourceCapture},
|
||||
|
||||
+52
-2
@@ -430,9 +430,59 @@ type door struct {
|
||||
// The rule holds only because those callers name those paths themselves. It does NOT
|
||||
// generalize to /v1/insights/e, whose callers arrive through an ingress rewrite — see
|
||||
// its entry below before applying a $source count to any door.
|
||||
// decodeEvent is the ONE door's decoder: the canonical wire, falling back to the
|
||||
// PostHog wire only when canonical yields NOTHING from a non-empty body.
|
||||
//
|
||||
// /v1/insights/e used to be a second door for the second wire. A wire is a SHAPE,
|
||||
// and a shape has never earned a path — decodeIngest already sniffs object-vs-array
|
||||
// and bare-vs-envelope on the same route, so sniffing one more encoding is the
|
||||
// mechanism it already is, not a new one. The two wires even share the `batch`
|
||||
// envelope key and differ only in per-event field names.
|
||||
//
|
||||
// The wire is chosen by SNIFFING THE KEYS, never by "did the first decoder return
|
||||
// anything". Trying canonical first and falling back on an empty result is WRONG and
|
||||
// the host-carve test proves it: decodeIngest ACCEPTS a PostHog body as a bare
|
||||
// canonical Event and returns ONE event, which is then dropped whole downstream
|
||||
// (canonicalType("") is "event", not in publicKinds). A count of 1 therefore does not
|
||||
// mean the body was understood, so a count-based fallback never fires and the event
|
||||
// silently vanishes with a 200 receipt.
|
||||
//
|
||||
// The two wires are distinguishable exactly, with no heuristic: canonical spells the
|
||||
// field `distinctId` (camel) and carries `type`; the PostHog wire spells it
|
||||
// `distinct_id` (snake) and carries `api_key`. Neither key exists in the other wire,
|
||||
// so presence is proof rather than a guess. Batches are probed on their elements
|
||||
// because the envelope key `batch` is shared by both.
|
||||
func isPostHogWire(body []byte) bool {
|
||||
var probe struct {
|
||||
DistinctID json.RawMessage `json:"distinct_id"`
|
||||
APIKey json.RawMessage `json:"api_key"`
|
||||
Batch []struct {
|
||||
DistinctID json.RawMessage `json:"distinct_id"`
|
||||
} `json:"batch"`
|
||||
}
|
||||
if json.Unmarshal(body, &probe) != nil {
|
||||
return false
|
||||
}
|
||||
if probe.DistinctID != nil || probe.APIKey != nil {
|
||||
return true
|
||||
}
|
||||
for _, e := range probe.Batch {
|
||||
if e.DistinctID != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decodeEvent(body []byte) ([]CaptureEvent, error) {
|
||||
if isPostHogWire(body) {
|
||||
return decodeInsights(body)
|
||||
}
|
||||
return decodeIngest(body)
|
||||
}
|
||||
|
||||
var doors = []door{
|
||||
{path: "/v1/event", decode: decodeIngest, source: sourceEvent},
|
||||
{path: "/v1/insights/e", decode: decodeInsights, source: sourcePostHog},
|
||||
{path: "/v1/event", decode: decodeEvent, source: sourceEvent},
|
||||
{path: "/v1/analytics", decode: decodeIngest, source: sourceCapture},
|
||||
{path: "/v1/analytics/batch", decode: decodeIngest, source: sourceCapture},
|
||||
{path: "/v1/tracker", decode: decodeIngest, source: sourceCapture},
|
||||
|
||||
@@ -164,8 +164,8 @@ func TestMount_HostCarve_IngestsForSiteOrg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// PostHog wire on /v1/insights/e.
|
||||
code := postHost(t, app, "yadota.hanzo.app", "/v1/insights/e",
|
||||
// PostHog wire on the ONE door /v1/event (decodeEvent falls back to the PostHog decoder).
|
||||
code := postHost(t, app, "yadota.hanzo.app", "/v1/event",
|
||||
`{"event":"$pageview","distinct_id":"d","properties":{"space":"attacker"}}`,
|
||||
map[string]string{"X-Org-Id": "attacker"})
|
||||
if code != http.StatusServiceUnavailable {
|
||||
|
||||
Reference in New Issue
Block a user