Sql Normalization
<!-- status: beta language: sql-normalization -->
SQL normalization and statement hash
One algorithm turns a SQL statement into literal-free text and a stable hash. Every implementation must produce the same bytes, so a span's db.query.hash joins agent DBM samples and the server-side wt.db.query_hash.
Reference:
services/worker/src/sqlnorm.rs(normalize_with). It stampswt.db.query_hashon spans that arrive with statement text.Node SDK:
sdks/node/src/sql.ts(normalizeSql,normalizeAndHash, export@watchtowerobservability/node/sql).Shared vectors:
testdata/sql_normalize_vectors.json. Each entry hasinput,pg,normalizedandhash. Every implementation must pass all of them.Note:
go/agent/ebpf/trace/sql.go(NormalizeSQL) is an older mirror. It differs on backslash escapes and on Postgres"identifiers", and it emits no hash. Port it to this spec before relying on agent hashes.
Input
The input is a statement string and the
pgdialect flag.pgis true whendb.system.name(or the legacydb.system) ispostgresql,cockroachdborredshift.Only the first 8192 UTF-8 bytes are read. The cut moves back to a whole character.
The text is processed as a sequence of Unicode scalar values (Rust
char). JS code must iterate by code point, not by UTF-16 unit.
Character classes
whitespace: Unicode
White_Space(Rustchar::is_whitespace). This is not JS\s, which also matches U+FEFF.alnum: Unicode
Alphabeticor general categoryN*(Rustchar::is_alphanumeric).word: alnum,
_or$.digit: ASCII
0-9only.
Scan (one left-to-right pass)
The scan keeps an output buffer and a space flag, both starting empty/false. At each position, the first matching rule applies:
Whitespace sets
spaceand advances one character./*opens a comment. Comments nest:/*adds a level and*/closes one. An unclosed comment runs to the end. Setsspace.--, or#when notpg, opens a comment that runs to the next\n. The\nitself is left for rule 1. Setsspace.Anything else. If
spaceis set and the output is non-empty, write one. Clearspace, then read one token:'(a string). Skip ahead to the next'that is not part of a doubled'', then write?. If a string is closed and the character just before the closing quote is\, discard the rest of the statement. The quote may have been a MySQL escape, so stopping here never leaks what follows. Otherwise continue after the closing quote."or`. The token runs to the next identical quote character, or to the end of the statement if there is none.Keep it verbatim, with both quotes, only when all of these hold: the quote is
",pgis true, the quote is closed, the body is 1-64 characters, and every body character is alnum,_or$.Otherwise write
?. MySQL and SQLite accept"alice"as a string, so a backtick-quoted identifier also becomes?.
$followed by a digit. Read$and all following digits, then write?.$opening a dollar-quoted string.Look up to 33 characters past the
$for the next$. The text between the two is the tag. The tag must be empty or contain only alnum or_, and its first character must not be a digit.The token runs past the next occurrence of the full
$tag$, or to the end of the statement if there is none. Write?.If no valid tag is found, the
$is handled as a word character (next rule).
digit. Read the run of word characters and
., then write?. This covers0x1Fand1.5e3.word character. Copy the run of word characters.
any other character. Copy that one character.
IN-list collapse (on the UTF-8 bytes of the scan output)
The collapse runs on the UTF-8 bytes of the scan output, from left to right. At each byte i:
The match is checked only when
iis at a word start:iis 0, or the previous byte, read as a Latin-1 character (U+0000-U+00FF), is not a word character. This is a byte-level quirk of the reference. The last UTF-8 byte oféis0xA9(©), which is not a word character, soéin (1)collapses. Implementations must reproduce this.Bytes
iandi+1must bein, compared case-insensitively. There is no check afterin.Then come optional spaces,
(, and one or more items, each?with optional spaces around it. Items are separated by,and the list ends with).On a match, copy the original bytes up to the
((theinand any spaces), write(?+), and continue after the). Otherwise copy byteiand move on.
The result is a normalized text such as SELECT * FROM t WHERE id IN (?+) AND n = ?.
Hash
hash = XXH3_64bits(UTF-8 bytes of the normalized text), with seed 0 and the default 192-byte secret. It is written as an unsigned decimal string, because 64-bit numbers lose precision in JSON. The Node SDK carries its own XXH3 port (BigInt). The vectors cover every length bucket (0, 1-3, 4-8, 9-16, 17-128, 129-240, more than 240, and more than 1024 bytes, which crosses the block scramble).
Span attributes
db.query.hash: always set on a span that has a statement.db.query.summary:<OPERATION> <first table>, taken from the normalized text. It is also the span name.db.query.text: the normalized text, only when the user opts in (queryText: trueorWATCHTOWER_DB_QUERY_TEXT=1). Raw text and parameters are never recorded.N+1 hint: a span gets
db.n_plus_one=trueanddb.query.repeat_count=<n>when the same hash has run more than N times (default 5) under one parent span. Only the repeats after the N-th carry the hint.
Changing the algorithm
The vectors file is the contract. To change the algorithm, update the Rust, the SDKs and the vectors in one change. Old and new hashes do not join, so call the change out in the changelog.