Blog· Tested on ClickHouse 26.4

Asking for tags on every logged query

Who is behind a query? A user account is too coarse and log_comment is a free-text string. I asked for a tags Map column in system.query_log; it has been open since 2022 with no maintainer decision. What the channels that exist actually give you, which of them can be trusted, and why altering the system table is a trap.

Source: github.com

system.query_log answers “what ran, how long, how much memory” very well. It answers “on behalf of whom” poorly. The identity ClickHouse authenticates is the database user, and that is rarely the identity you care about. A web application connects as one account, app_reader, and behind it sit thousands of end users, dozens of dashboards and a handful of background jobs. When the cluster is slow, the question is never “which account?” but “which dashboard, which user segment, which job?”

I filed #36218 in April 2022 after seeing the same workaround in several unrelated places: teams write a middleware that copies system.query_log into a table of their own with extra columns (end_user, segment, feature) and build their load analysis there. When that pattern keeps being rebuilt, something belongs in the product. I asked for a tags Map(String, String) column, filled from something the client sends with the query, so the question becomes:

SELECT tags['dashboard'] AS dashboard, count() AS queries, sum(query_duration_ms) AS total_ms
FROM system.query_log
WHERE type = 'QueryFinish'
GROUP BY dashboard
ORDER BY total_ms DESC;

I also floated making it JSON rather than Map, with the caveat that I might be asking too much.

What the tracker holds

Very little, and that is the finding. The issue carries the labels feature, minor (priority), comp-system-table and external (the author is not a ClickHouse organization member). There is one reply, from UnamedRus the next day, and my answer a day after that. No maintainer has commented, no PR references it, and I found no other issue or PR about query tags in query_log when I searched the repository for “query tags”, “query_log tags” and “log_comment”. It is open, undecided, four years on. Anything below about why is therefore my analysis, not the maintainers’ reasoning, and I mark it as such.

UnamedRus suggested a trick: put JSON into log_comment and add a materialized column over it,

ALTER TABLE system.query_log ADD COLUMN tags Object('JSON') MATERIALIZED log_comment;

I called it “a really good hack” and “still a workaround”, which is the whole disagreement in a sentence. It deserves a longer look than I gave it then, because both halves of it have changed.

What the table already has

From the query_log documentation and QueryLog.cpp, the columns that say something about who:

Column Where the value comes from Granularity
user, initial_user authentication one per database account
quota_key the client, when a quota is keyed one string
client_name, interface the client library per tool
http_user_agent, http_referer, forwarded_for HTTP headers per HTTP client
log_comment the log_comment setting any string up to max_query_size (262144 here)
Settings settings changed for this query Map(LowCardinality(String), LowCardinality(String))

Only the first row is identity the server vouches for. Everything else is a claim made by the client. That distinction is the first real design tension in the request: a tags column would be the most flexible and the least trustworthy column in a table people use for chargeback and incident forensics.

The second is that Settings already is a per-query Map. Every setting changed for a query is logged there (log_query_settings defaults to 1; I checked on 26.4). A client can already attach structure through a channel the server parses, transports on every protocol and, for settings, can constrain. Custom settings are the intended way to carry your own keys, but they are off by default; on 26.4 without the server option:

Code: 115. DB::Exception: Unknown setting 'SQL_dashboard'. (UNKNOWN_SETTING)

They are enabled with the server option custom_settings_prefixes (for example SQL_), which is a server configuration change, not a code change. I could not enable it on clickhouse local, and I did not run a server for this page, so I cannot show you the resulting Settings map. The code that fills the column walks the query’s settings object, so I expect custom ones to appear; treat that as untested.

Alternatives, and what each costs

log_comment, set per session or per query. It is an ordinary setting, so every client can set it and the query can carry it inline:

SET log_comment = '{"app":"dash","user":"u42","n":7}';
SELECT getSetting('log_comment') AS lc, JSONExtract(lc, 'Map(String,String)') AS tags FORMAT Vertical;
Row 1:
──────
lc:   {"app":"dash","user":"u42","n":7}
tags: {'app':'dash','user':'u42','n':'7'}

That is the Map I asked for, made at read time, with numbers stringified. Two properties matter for dashboards that mix comments: a comment that is not JSON does not error, it yields an empty map (JSONExtract('just a comment', 'Map(String,String)') returned {}), and nested values come back as raw JSON text ({"a":{"b":1},"c":[1,2]} gives a -> {"b":1}, c -> [1,2]). Anything else that writes log_comment for its own reasons, such as the test suites that use it to find their own queries, silently drops out of the grouping.

If you do not want JSON, extractKeyValuePairs parses a flat convention. The default key-value delimiter is a colon, and the delimiter is a parameter:

SELECT extractKeyValuePairs('job:nightly team:data') AS tags, tags['team'];
SELECT extractKeyValuePairs('job=nightly team=data', '=') AS tags;
{'job':'nightly','team':'data'}	data
{'job':'nightly','team':'data'}

('job=nightly team=data' with no delimiter argument gave {}, so that one is easy to get wrong.) The advantage over JSON is a shorter string and no quoting; the cost is that the format is yours to defend.

A materialized column on the log. This is UnamedRus’s trick, and the original no longer runs: on 26.4 the type is gone (Unknown data type family: Object. (UNKNOWN_TYPE)). The modern versions work, shown here on a stand-in table with the system.query_log columns that matter, since clickhouse local has no query_log:

CREATE TABLE ql_demo (type String, query_duration_ms UInt64, log_comment String,
  tags Map(String, String) MATERIALIZED JSONExtract(log_comment, 'Map(String,String)'))
ENGINE = MergeTree ORDER BY tuple();
INSERT INTO ql_demo (type, query_duration_ms, log_comment) VALUES
 ('QueryFinish', 120, '{"dashboard":"sales","user":"u1"}'), ('QueryFinish', 900, '{"dashboard":"sales","user":"u2"}'),
 ('QueryFinish', 40, '{"dashboard":"ops","user":"u1"}'), ('QueryFinish', 15, ''), ('ExceptionBeforeStart', 0, 'free text');

SELECT tags['dashboard'] AS dashboard, count() AS queries, sum(query_duration_ms) AS total_ms
FROM ql_demo WHERE type = 'QueryFinish' GROUP BY dashboard ORDER BY total_ms DESC;
sales	2	1020
ops	1	40
	1	15

The same works with a JSON column (tags JSON MATERIALIZED log_comment::JSON, then tags.dashboard), and ALTER TABLE ... ADD COLUMN ... MATERIALIZED over existing rows evaluates on read. Note the empty-key group: untagged queries are in the result, and you will want WHERE dashboard != ''.

The problem is not the SQL, it is where you run it. system.query_log is not your table. SystemLog::prepareTable compares the table’s CREATE statement with the one the server expects, and when they differ it logs that the table “has obsolete or different structure” and renames the existing one to query_log_0 (then _1, …), creating a fresh, standard table. Reading the source, an ALTER on a system log therefore looks like it lasts until the next time the server prepares the table, after which your column lives on in a rotated table and the live one has no tags. I did not run a server to watch this happen, so check it on a test node before relying on either reading. The robust alternative is to leave the table alone and put the extraction in a view:

CREATE VIEW query_log_tagged AS
SELECT *, JSONExtract(log_comment, 'Map(String,String)') AS tags FROM system.query_log;

I only syntax-checked that statement with clickhouse format, since it needs a server. A view survives rotation. It pays the JSON parse on every read rather than once at insert, which for the volumes in query_log rarely matters.

query_id conventions. The client picks the id, so dash-sales-<uuid> is searchable with startsWith. But the id is also the handle other features use to find and kill a query (the replace_running_query setting, default 0, exists because ids collide), so the tag lives inside a value that has other jobs, and a prefix carries one dimension at most.

Pinning a tag server-side. For the dimension you can trust, bind it to the account and lock it, so the client cannot rewrite it. Syntax-checked with clickhouse format, not run:

CREATE USER app_reader SETTINGS log_comment = 'app_reader' CONST;

That works per account, which is exactly the granularity the request was trying to get past. See Users, roles and settings profiles for CONST.

Why I think it stalled, and why that is defensible

This section is opinion. A tags column asks the server to take a position on three things it currently avoids. It would fix a schema contract in a table whose schema the server owns and rotates. It would put an unbounded, client-controlled Map on the write path of every query, where today the cost is one string. And it would need a transport: a new client option, or an HTTP header, or an overloaded log_comment, each of which has to work on the native protocol, HTTP and the Postgres/MySQL wire interfaces. Against that, the existing log_comment already crosses every protocol, and SQL can turn a string into a map at read time. A minor priority label is consistent with “there is a workable path already”.

The counter-argument, which is why I still care, is the one in the first paragraph: a workaround that every team re-derives is a missing convention. Today nothing documents a shared tags name, a recommended encoding for log_comment, or that custom settings are the structured option. The cheapest useful step would be documentation, not a column.

Where it stands

Status on 2026-10-14: open, no maintainer decision, no linked work. On 26.4 the pieces to build your own are better than in 2022: JSON and Map are first-class, JSONExtract and extractKeyValuePairs do the parsing, and log_comment works on every client. What is still missing is the part I asked for, a documented column so tools and saved queries share one name. For the JSON side of the workaround see Working with JSON in ClickHouse, and for what to do with the attributed data once you have it, Tracking query and resource trends and Most resource-intensive queries.

The pattern is general. ClickHouse tends to expose a primitive (a string setting) and leave structure to SQL at read time, and system tables are a contract the server controls, not a place to extend. A feature request that wants to turn a convention into a schema runs into both at once.

feature-requestsystem-tablesobservability

Discussion

Comments live in GitHub Discussions. Sign in with GitHub to join in.