mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
`event.error.org` held ONE tenant under TWO spellings: 86 rows as cd35a51b-f7e7-5412-b67f-58b8703e5219 and 2 as `hanzo`, measured on the live warehouse 2026-08-02, newest uuid row 02:08 — so the writer was doing it then. They are the same tenant. That uuid is uuid5(NameSpaceURL, "hanzo:o11y:org:hanzo"), which iamidentn mints from the slug. A reader binding either spelling saw a fraction of its own errors, and `product` was split the same way: a Sentry project uuid where every other writer says `docs`. The slug wins, because the uuid is DERIVED FROM it. A projection can always be recomputed from the original; the original cannot be recovered from the projection. `org` is also LowCardinality and leads every sort key in the namespace, every other writer and reader on the plane already says slug, and the plane's own DDL says so in as many words. So the two vocabularies now meet in ONE place. `Scope` (plane.go) resolves the ids this module works in to the names the plane stores, reading each from the record that owns it — the org row and the project row — and memoizing, because neither name has an update path. It is a constructor argument rather than an option: a store that could be built without one could write a row under a spelling no reader binds. It fails CLOSED — an id with no name refuses the operation rather than writing under a placeholder, since a bucket nobody owns is a bucket several tenants share. `row` is extracted as a pure function so the written row can be asserted directly: the regression gate reads `org` and `product` off the result, rather than trusting the call that produced it. And the tenant contract stops being a comment. 0002 asserted it with two bare SELECTs at the end of the file — a check that printed a table and was believed by whoever read it, and which would have FAILED on 0002's own backfill. It is now a CONSTRAINT on event.fact, evaluated on every insert forever, plus a precondition over the four source tables that refuses to start the backfill rather than aborting it halfway. Verified against the live engine (26.2.3): a uuid org and a uuid product are each rejected with VIOLATED_CONSTRAINT and the table is unchanged; the 36-character slug `maxpower-team-alpha-beta-gamma-delta` is admitted — which is why the predicate is the uuid grammar and not `toUUIDOrNull(x) IS NULL`, a spelling that reads better and would refuse a real tenant's telemetry. The precondition fires against the live sources today. Repairing the 88 already-written rows is the operator's, because the uuid -> slug map is this deployment's; the derivation that proves each pair is in the note. Co-authored-by: Hanzo Dev <[email protected]>
118 lines
4.8 KiB
Go
118 lines
4.8 KiB
Go
package implsentry
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
dsmock "github.com/hanzo-ds/mock"
|
|
"github.com/hanzoai/o11y/pkg/telemetrystore"
|
|
"github.com/hanzoai/o11y/pkg/telemetrystore/telemetrystoretest"
|
|
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
|
|
"github.com/hanzoai/o11y/pkg/valuer"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// anyMatcher lets the mock match on expectation type + args, not exact SQL text —
|
|
// the SQL text itself is asserted by the pure builder tests (eventsql_test.go).
|
|
type anyMatcher struct{}
|
|
|
|
func (anyMatcher) Match(string, string) error { return nil }
|
|
|
|
// A read must execute exactly one query and NOTHING else. The store used to run
|
|
// CREATE DATABASE + CREATE TABLE before every operation, which recreated the database
|
|
// it names on the next process start — the way a dropped database comes back from the
|
|
// dead. Only the query below is expected here, so any DDL the store issued would be an
|
|
// unmatched call and fail this test.
|
|
func TestEventStore_ReadsWithoutIssuingDDL(t *testing.T) {
|
|
provider := telemetrystoretest.New(telemetrystore.Config{}, anyMatcher{})
|
|
mock := provider.Mock()
|
|
mock.MatchExpectationsInOrder(false)
|
|
|
|
// DistinctFingerprints → one String column. The 4 bound args are the (org,
|
|
// project, from, to) tenant+window scope every read carries.
|
|
mock.ExpectQuery("SELECT DISTINCT").
|
|
WithArgs(nil, nil, nil, nil). // nil = match-any (dsmock.matchArg); 4 = the tenant+window scope
|
|
WillReturnRows(dsmock.NewRows(
|
|
[]dsmock.ColumnType{{Name: "group", Type: "String"}},
|
|
[][]any{{"fp-1"}, {"fp-2"}},
|
|
))
|
|
|
|
store := NewEventStore(telemetrystore.TelemetryStore(provider), namedScope("acme", "docs"))
|
|
fps, err := store.DistinctFingerprints(context.Background(), valuer.GenerateUUID(), valuer.GenerateUUID(), testWindow())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"fp-1", "fp-2"}, fps)
|
|
|
|
require.NoError(t, mock.ExpectationsWereMet())
|
|
}
|
|
|
|
// TestEventStoreTargetsEventError pins WHICH table the plane reads and writes. The
|
|
// database is named for what it holds and the table for what it is.
|
|
func TestEventStoreTargetsEventError(t *testing.T) {
|
|
s := NewEventStore(nil, namedScope("acme", "docs")).(*eventStore)
|
|
assert.Equal(t, "event", s.db)
|
|
assert.Equal(t, "error", s.table)
|
|
}
|
|
|
|
// TestInsertMatchesAppend pins the insert-sink invariant: the INSERT column list and
|
|
// the batch.Append argument list are the same length and order, and the read
|
|
// projection scans exactly as many columns as it selects — so a written row reads back
|
|
// field-for-field.
|
|
func TestInsertMatchesAppend(t *testing.T) {
|
|
assert.Equal(t, 26, countCols(insertColumns), "insert writes 26 columns")
|
|
assert.Equal(t, 28, countCols(selectColumns),
|
|
"the read projection selects the 5 frame arrays plus the attribute-backed fields")
|
|
}
|
|
|
|
// countCols counts a comma-separated column list. Every expression in these lists is
|
|
// a bare column or a constant map/array subscript, none of which contain a comma.
|
|
func countCols(list string) int { return strings.Count(list, ",") + 1 }
|
|
|
|
// Frames survive the round trip through the five parallel arrays event.error stores.
|
|
func TestFramesRoundTrip(t *testing.T) {
|
|
in := []sentrytypes.Frame{
|
|
{Function: "handle", File: "app/svc.py", Line: 42, Column: 7, Own: true},
|
|
{Function: "connect", File: "inpage.js", Line: 1, Column: 84179, Own: false},
|
|
}
|
|
fn, file, line, col, own := unzipFrames(in)
|
|
assert.Equal(t, in, zipFrames(fn, file, line, col, own))
|
|
}
|
|
|
|
// A short parallel array must read as a zero value, never panic — the shape a row
|
|
// written before a frame column existed has.
|
|
func TestFramesTolerateShortArrays(t *testing.T) {
|
|
got := zipFrames([]string{"handle", "connect"}, []string{"only-one.py"}, nil, nil, nil)
|
|
require.Len(t, got, 2)
|
|
assert.Equal(t, "only-one.py", got[0].File)
|
|
assert.Equal(t, sentrytypes.Frame{Function: "connect"}, got[1])
|
|
}
|
|
|
|
// No frames means no frames — not one empty frame.
|
|
func TestFramesEmpty(t *testing.T) {
|
|
assert.Nil(t, zipFrames(nil, nil, nil, nil, nil))
|
|
fn, file, line, col, own := unzipFrames(nil)
|
|
assert.Empty(t, fn)
|
|
assert.Empty(t, file)
|
|
assert.Empty(t, line)
|
|
assert.Empty(t, col)
|
|
assert.Empty(t, own)
|
|
}
|
|
|
|
// attributesOf folds tags plus the envelope-adjacent values into the one map column,
|
|
// without mutating the caller's tag map.
|
|
func TestAttributesOfDoesNotMutateTags(t *testing.T) {
|
|
tags := map[string]string{"release_channel": "beta"}
|
|
e := &sentrytypes.Event{Tags: tags, Platform: "python", ServerName: "web-1"}
|
|
|
|
attrs := attributesOf(e)
|
|
assert.Equal(t, "beta", attrs["release_channel"])
|
|
assert.Equal(t, "python", attrs["platform"])
|
|
assert.Equal(t, "web-1", attrs["server"])
|
|
assert.NotContains(t, attrs, "user_email", "an empty value is not stored")
|
|
|
|
assert.Equal(t, map[string]string{"release_channel": "beta"}, tags, "the caller's map is untouched")
|
|
}
|
|
|
|
var _ sentrytypes.EventStore = (*eventStore)(nil)
|