Inside Microsoft's Telemetry Benchmark
Dr. Jörg Domaschka · 12 November 2026
How fast can your database search, filter and aggregate billions of log lines? Logs and telemetry are among the fastest-growing kinds of data: every service, device and pipeline writes events, and operations teams expect answers within seconds.
In 2022, Microsoft and GigaOm published a benchmark for exactly this workload: the Telemetry Logs Generator and Benchmark, a data generator for up to 100 TB of synthetic logs plus 19 queries written in the Kusto Query Language (KQL). This article takes the benchmark apart: where it comes from and how GigaOm used it in 2022, what its data looks like in different sizes, what its queries ask, and what it takes to port them to SQL without changing their meaning. It closes with news: benchANT has integrated the benchmark into its benchmarking tooling.
Where the benchmark comes from
Microsoft built the benchmark for its analytics service Azure Data Explorer (ADX), also known as Kusto, and published it on GitHub. The repository grew in steps: the data generator arrived in February 2021, the KQL queries in December 2021, and GigaOm's test code in July 2022, shortly before GigaOm's report of 15 August 2022 and Microsoft's announcement a day later. Microsoft archived the repository on 15 June 2026: it is read-only now, but still available.
The benchmark simulates the logs of a cloud service that ingests files from many sources: ingestion commands, file downloads, completion events with row counts and durations, warnings, errors and the occasional crash. It consists of two parts:
- A data generator, a C# command-line tool. It writes the logs as gzip-compressed CSV files without a header row, to a local disk or to Azure Blob Storage, in four sizes: 1 GB, 10 GB, 1 TB and 100 TB. In all sizes, the first events are dated 8 March 2014.
- 19 queries that answer typical diagnostic and monitoring questions, written in KQL. The repository's
GigaOmdirectory holds these queries together with GigaOm's translations to BigQuery SQL and Snowflake SQL, table definitions and loading instructions for the three systems, and the Python driver GigaOm used for its measurements.
Two sets of KQL queries. The repository's top-level queries directory holds a second, older set of KQL queries, plus translations to Spark SQL. The two sets differ in four queries: Q03 searches for a different string, Q10 lists its grouping columns in a different order, Q16 is a different query altogether, and the older Q17 is a copy of Q15 with a shorter time window. GigaOm's measurements use the set in the GigaOm directory (although the report's summary table still describes the older Q16). All query descriptions in this article refer to that set.
GigaOm's 2022 study in brief
GigaOm's Log and Telemetry Analytics Performance Benchmark (v1.01, 15 August 2022, by William McKnight and Jake Dolezal) is the study the benchmark was published with. Microsoft sponsored it and, as the report states, chose the competitors, the test and the ADX configuration. GigaOm chose the Snowflake configuration closest to the ADX cluster in CPU and memory, and ran BigQuery with reserved slots. All three systems held the 100 TB data set.
| Azure Data Explorer | Google BigQuery | Snowflake | |
|---|---|---|---|
| Configuration | 9 nodes L16as_v3 | 2,000 slots, 100 GB BI Engine | X-Large warehouse (16 nodes), Enterprise Edition |
| Cost per hour | $32 | $55 | $48 |
| Data layout | default | partitioned by hour; clustered by ClientRequestId, Source, Level, Component | clustered by Timestamp, Level, Source, Node, Component; search optimization |
| Loading 100 TB | about 4 hours | about 10 hours | about 22 hours |
| 1 user (average of 3 runs) | 3.15 s | 309.01 s | 287.24 s |
| 50 users (one run) | 54.52 s | 1,159.15 s | 1,238.47 s |
ADX was the fastest system in all 19 queries with one user and in 18 of 19 with 50 concurrent users. With 50 users, BigQuery and Snowflake each ran into the two-minute timeout in eight queries. From costs and run times, GigaOm derives a 97% price-performance advantage for ADX: a run rate of $0.48 per hour, against $17.71 for BigQuery and $16.51 for Snowflake. Elasticsearch and AWS OpenSearch were attempted but not tested. With 80 Logstash instances, Elastic Cloud indexed 500 GB per hour, so loading the 100 TB would have taken about eight days; the data would have grown instead of being compressed; and hot storage couldn't be scaled independently of compute. GigaOm puts the test costs at well over $30,000 for Elastic Cloud and nearly $10,000 for OpenSearch.
GigaOm itself writes: "We leave the test's fairness for the reader to determine." Read next to the published code, the report leaves several questions open:
- Repetitions. The text says each query ran 20 times in a row, with medians and 95th percentiles reported. The result tables show averages instead: over three consecutive runs with one user, over a single run with 50 users.
- Timeouts. Queries that ran longer than two minutes were cancelled and counted as 120 seconds. With 50 users, this affects eight of 19 queries on BigQuery and on Snowflake, so their sums are lower bounds.
- What was timed. The report doesn't say whether it shows client-side or server-side times. The published driver records both. For ADX and BigQuery, the server-side value is the execution time the service reports; for Snowflake, both values are the time measured on the client, including fetching the result.
- Caches. The driver turns BigQuery's query cache off; it sets nothing for Snowflake or ADX, and the report doesn't mention caches.
- Query semantics. The translations deviate from the KQL originals in several queries; the section on correct ports below gives examples.
The study's core metric, the sum of the average execution times of all 19 queries, is simple and useful, as long as a measurement defines each of these points.
The data and how it is built
All data lives in a single table, Logs, with one row per log event:
| Column | Meaning | Type in ADX |
|---|---|---|
Timestamp | When the event occurred | datetime |
Source | What triggered the event | string |
Node | The node (server) where the event originated | string |
Level | Severity: Information, Warning, Error or Critical | string |
Component | The application component that wrote the message | string |
ClientRequestId | The request (session) that triggered the event | string |
Message | The full text of the log message; can span several lines | string |
Properties | Optional JSON document: file lists, sizes, row counts, durations | dynamic |
The generator's source code explains most properties of the data:
- Sources. Each source is one generator thread; how many there are depends on the size of the data set. A source's name is a word from a fixed list plus a number, such as
IMAGINEFIRST0orCLIMBSTEADY83. Every source starts at midnight on 8 March 2014 and opens a new ingestion session every 0 to 63 seconds until it has used up its quota of sessions. - Sessions. Each session has its own
ClientRequestId, a GUID. It ingests 5 to 104 files of one format (csv, json, parquet or avro) into one of 607 tables. For each file, it writes a download event, a series of background traces, and anIngestionCompletionEvent, or an exception if the session fails. A final report closes the session. These ingestion events come from four components of their own:INGESTOR_GATEWAY,DOWNLOADER,INGESTOR_EXECUTERandFINALIZER. - Background traces make up the vast majority of rows: about 50 to 5,000 per session, spread over 1,000 nodes (
Engine000000000000toEngine000000000999), 128 components and seven message templates. About one in ten has level Warning, the rest Information. - Failures. About 2.5% of the sessions are marked as failed and about 0.26% as critical. Such a session writes an exception with one of 104 exception types in place of each completion event (this is where all Error rows come from), and a critical session adds one Critical alert at the end.
- Properties. Only three kinds of events carry JSON. An ingestion command lists the session's file URLs as a JSON array. A download event holds
compressedSize,OriginalSizeanddownloadDuration. A completion event holdssize,format,rowCount,cpuTimeandduration. Sizes and row counts are integers; durations are text in .NET's format[d.]hh:mm:ss.fffffff, for example1.02:03:04.5678901for one day, two hours and a bit more. All other events leavePropertiesempty. - Timestamps advance in steps of 100 nanoseconds and are written with seven fractional digits, the resolution of ADX's
datetime. - Error messages span several lines: an exception header with
Exception=…;,HResult=…;andMessage=exception happened;on separate lines, followed by a stack trace.
Data properties in different data sets
The generator doesn't create a larger data set by logging the same system for a longer time. It adds sources, and it gives each source more sessions. The two largest sizes, 1 TB and 100 TB, therefore differ in more than volume:
| 1 TB | 100 TB | |
|---|---|---|
| Rows | 4.09 billion | over 404 billion |
| Time span | 8.5 days | nearly three months |
| Rows per hour | about 31 million on the first days, fewer later | 250 to 300 million |
| Size | about 1 TB uncompressed, 128.5 GB gzip-compressed | 100 TB uncompressed, about 12 TB gzip-compressed |
| Column | 1 TB | 100 TB |
|---|---|---|
Source | 100 | about 1,000 |
Node | 1,000 | 1,000 |
Component | 132 | 132 |
Level | 4 | 4 |
ClientRequestId | one per session; 1,920 to 25,896 sessions per source | one per session; 19,200 to 258,960 sessions per source |
The 1 TB data set is therefore not a 1% sample of the 100 TB data set. It has a tenth of the sources, and each runs a tenth of the sessions: the result is a tenth of the hourly volume over a tenth of the time span. Nodes, components, levels, tables and message templates are the same in both sizes.
Within a data set, the rows are not spread evenly over time. All sources start at the same moment but run different numbers of sessions, so the volume falls from day to day as more and more sources finish. Loaded into a database, the 1 TB data set holds 4,091,441,279 rows from 2014-03-08 00:00 to 2014-03-16 12:40 UTC, in 54,288 gzip-compressed CSV files.
In the 100 TB data set, each source runs ten times as many sessions at the same pace, so the same decline stretches over about three months.
The 19 queries
The queries fall into a few families: text search in messages, point lookups, aggregations over time windows, extraction of values from message text and JSON properties, and joins. 17 of the 19 queries look at a time window between one hour and ten days; only Q02 and Q05 search the whole data set.
| Query | What it asks | Time window |
|---|---|---|
| Q01 | How many warnings mention the term “enabled”? | 12 h |
| Q02 | How many errors mention the term “safeArrayRankMismatch”? | whole data set |
| Q03 | Per component: how many events from sources starting with “im” contain “ponse” in the message? | 1 h |
| Q04 | The 1,000 most recent events in which any column contains the term “Internal” | 1 h |
| Q05 | How many events belong to eight given client request IDs? | whole data set |
| Q06 | How many events of four sources fall into each of four log types (derived from the component)? | 10 days |
| Q07 | The ten most frequent exception types and messages among errors (parsed from the message text) | 1 h |
| Q08 | How many events per level? | 6 h |
| Q09 | The 50 most frequent combinations of level and component | 6 h |
| Q10 | For one source: the ten requests (with component, level, node) with the most events mentioning “downloaded” | 3 days |
| Q11 | For the ten nodes with the most errors: how many events per level? | 6 h |
| Q12 | Per hour: how many distinct requests had errors? | 6 h |
| Q13 | For twelve sources: maximum row count and duration percentiles of the completion events whose properties mention “parquet” | 6 h |
| Q14 | The ten sources with the highest download rate (compressed size / download duration, from the JSON properties) | 1 h |
| Q15 | For one source: the average CPU time per 5-minute interval of the ten nodes with the highest CPU time (from the JSON properties) | 5 days |
| Q16 | Per source: first and last error with its component, and the error count; the five sources with the most errors | 3 h |
| Q17 | For one source: ingestion commands (table name parsed from the message) joined with the completion events of the same request; the ten tables with the most rows | 3 days |
| Q18 | Like Q17, but the ten tables with the most rows per data format | 3 days |
| Q19 | For one source: how many download and completion events refer to the same file path but to different requests? | 3 days |
All queries are static. Unlike the TPC benchmarks, they have no parameters, so every run sends exactly the same query text. A system with a result cache can therefore answer every repetition from the cache without touching the data. Every measurement with this benchmark has to state how caches were set.
What the queries see
All query time windows start on 8 March 2014, where the data is densest. In the 1 TB data set, a three-day window covers 53% to 54% of all rows, and Q06's ten-day window covers all of them. In the 100 TB data set, the same windows cover a much smaller share of the data. The mix of levels doesn't depend on the size: in a typical six-hour window, 90.3% of the rows have level Information, 9.7% Warning, 0.05% Error and only a handful Critical. Filters on errors are therefore very selective, while filters on informational events hardly filter at all.
The source filters behave differently in the two sizes. In the 1 TB data set, Q03's prefix im matches a single source, IMAGINEFIRST0, and of the four sources that Q06 names, only IMAGINEFIRST0 exists. The other three would be generated only for 100 TB, and not even there with the generator's current list of names: their numbers point two places past the positions of their names in the list. The 100 TB data GigaOm used was therefore presumably generated with a slightly different list. All twelve sources that Q13 names exist in both sizes.
From KQL to SQL
The benchmark was originally defined for ADX with queries specified in KQL. This makes the KQL queries the golden copy of the benchmark and therefore the specification for other systems the benchmark should be ported to. Hence, porting the benchmark means mapping each query including its KQL operators to an expression in another language such as SQL with the same meaning. The following table presents frequently used KQL operators and suggestions for an SQL port.
| KQL operator | What it does | Used in | What it means for a SQL port |
|---|---|---|---|
where … between | Restricts the rows to a time window | 17 queries (all but Q02, Q05) | A range filter, inclusive at both ends; how fast it is depends on how the data is physically ordered |
has | Case-insensitive search for a whole term; * has searches all columns | Q01, Q02, Q04, Q10, Q13 | No direct SQL equivalent: LIKE '%term%' also matches inside longer words; needs regular expressions or a token function |
contains, startswith | Case-insensitive substring and prefix match | Q03, Q07, Q13, Q15, Q17, Q18, Q19 | Case-insensitivity has to be reproduced explicitly |
summarize, count | Aggregation: counts, maxima, averages, … | 18 queries (all but Q04) | Maps to GROUP BY; two of its functions are estimates (next rows) |
dcount | Estimated number of distinct values (HyperLogLog) | Q12 | An exact COUNT(DISTINCT …) can differ from the estimate |
percentiles | Estimated nearest-rank percentiles (T-Digest) | Q13 | The percentile definition has to match: nearest rank, not interpolation |
top | The first N rows by a sort order, descending by default | Q04, Q07, Q09, Q10, Q11, Q14, Q16, Q17; Q15 with order by and take | ORDER BY … DESC LIMIT n; empty values last; ties have to be handled the same way |
parse | Extracts values from text by a pattern | Q07, Q17, Q18, Q19 | Regular expressions; anchored, lazy, and * must cross line breaks (see below) |
join, in with a subquery | Combines two intermediate results | Q11, Q15, Q17, Q18, Q19 | Standard SQL; joins back on minimum or maximum timestamps duplicate rows on ties |
Properties.<field> with tolong, todouble, totimespan | Reads a value from the JSON column and converts it | Q13, Q14, Q15, Q17, Q18 | Depends on how the JSON is stored; durations have to be parsed from text |
top-nested, arg_min/arg_max, bin, case | Hierarchical top-N, the row with the smallest/largest value, time buckets, case distinctions | Q18; Q16; Q12, Q15; Q06 | Expressible in SQL with window functions and CASE |
Some examples, in standard SQL and, where SQL lacks a function, in ClickHouse SQL:
- Time windows.
Timestamp between (datetime(2014-03-08) .. 12h)becomesTimestamp BETWEEN TIMESTAMP '2014-03-08 00:00:00' AND TIMESTAMP '2014-03-08 12:00:00'. Both ends are inclusive, so the precision of the stored timestamps matters: a table that keeps only milliseconds truncates events from just after the end of the window onto it. On the 1 TB data set stored that way, Q01 counts one row too many, Q08 eight, and Q09 one in each of four groups. Keep all seven fractional digits (in ClickHouse:DateTime64(7)). - Term search.
Message has 'enabled'ignores case and matches whole terms only; KQL defines a term as a maximal sequence of alphanumeric characters. A regular expression with word boundaries comes close, for examplematch(Message, '(?i)\\benabled\\b')in ClickHouse. One subtlety remains:\btreats_as part of a word, and the KQL documentation doesn't say whether_separates terms. ClickHouse's token functions, such ashasToken(lowerUTF8(Message), 'enabled'), are an alternative that can use a token index. - Prefixes and substrings.
Source startswith 'im'becomesLOWER(Source) LIKE 'im%', andMessage contains 'PONSE'becomesLOWER(Message) LIKE '%ponse%'. ClickHouse has dedicated functions for both:startsWithCaseInsensitiveUTF8andpositionCaseInsensitiveUTF8. - Top N.
top 1000 by Timestampreturns the 1,000 most recent rows: KQL'stopsorts in descending order unless told otherwise, with empty values last. The port isORDER BY Timestamp DESC NULLS LAST LIMIT 1000, notORDER BY Timestamp LIMIT 1000. - Aggregations and subqueries.
summarize Count=count() by LevelbecomesSELECT Level, count(*) AS Count … GROUP BY Level, andlet top_nodes = …;becomes a common table expression,WITH top_nodes AS (…). - Durations.
totimespan(Properties.duration)parses text such as1.02:03:04.5678901. The seven fractional digits count 100-nanosecond ticks; reading them as microseconds makes a duration up to ten seconds too long. - Estimates. For Q12, an exact count is fine: at its default accuracy,
dcountcounts exactly up to 1,000 distinct values, and Q12 finds about 370 to 410 requests per hour in the 1 TB data set. For Q13, a port should compute exact nearest-rank percentiles (for example withpercentile_disc), not interpolated ones (percentile_cont) or estimates from a random sample (ClickHouse'squantiles).
Pattern extraction needs the most care. Q07 extracts the exception type and message with parse:
| parse Message with
'Exception=' ExceptionType ';' *
'Message=' ExceptionMessage ';' *A regular expression has to reproduce three properties of this pattern: it is anchored at the start of the message, each captured value ends at the next literal, and * crosses line breaks. The last point is not academic: in Q07's time window in the 1 TB data set, all 16,991 matching messages have Message= on a later line than Exception=. In ClickHouse, extractGroups(Message, …) with the following expression returns both values; the flag (?s) lets . match line breaks:
(?s)^Exception=(.*?);.*?Message=(.*?);The KQL documentation doesn't spell out these details of parse; they follow from its examples.
What makes a port correct
The KQL queries of the GigaOm directory define what each query is supposed to return. A port to another system is correct if it meets two conditions:
- It returns the same rows as its KQL original on the data set.
- It means the same. Equal results are necessary, but not sufficient. Q04 searches all columns for the term “Internal” (
* has 'Internal'). In the generated data, onlyMessagecontains it, so a query that searchesMessagealone returns the same rows. But it is a different query, one that would miss matches in other data, and it violates the spirit of the benchmark.
Two kinds of differences are allowed, because KQL leaves them open itself. Where a top-N cut-off falls into a tie, any of the tied rows is a valid answer: in the 1 TB data set, Q10's top 10 ends in a tie (four groups with a count of 3, many with a count of 2), so only the rows above the tie can be compared. And where KQL returns estimates, an exact result may differ slightly.
Measured against these rules, GigaOm's translations deviate from the KQL originals in several places. Some examples from the published code:
- The Snowflake port searches Q01's and Q02's terms as substrings (
regexp_like(Message, '.*enabled.*', 'is')), not as whole terms. - Snowflake's Q10 window ends twelve hours late: it covers three and a half days instead of three.
- Both ports sort Q04 in ascending order and thus ask for the oldest rather than the most recent events.
- Both ports extract Q07's values with greedy, unanchored expressions (
Exception=(.*);). - The Snowflake port interpolates Q13's percentiles (
percentile_cont). - Both ports pick Q15's top nodes as the ten combinations of node and CPU time with the highest CPU time. One node can occur several times, so fewer than ten distinct nodes may be chosen.
- Both ports of Q16 join back on the timestamps of the first and last error. This can duplicate rows on ties and take the component from a non-error event with the same timestamp.
Altogether, the Snowflake translation deviates from the KQL originals in 14 of the 19 queries. On the 1 TB data set, none of these deviations changes the returned rows, but on other data, some could.
Now in benchANT's tooling
We have integrated Microsoft's telemetry benchmark into benchANT's benchmarking tooling. The 19 queries run as a custom workload in our YCSB harness: YCSB executes the queries, takes the measurements and controls the client threads, and bindings connect it to the database under test. Adding a system requires a binding, a port of the queries and a load of the data. For loading data, we use the benchmark's data generator.
We plan to have bindings ready for Snowflake, ClickHouse and Google BigQuery with improved query mappings. These will be presented in separate blog articles.
Do you run log or telemetry analytics on one of these systems, or on another one? Talk to us: we can measure how your system and configuration handle this workload, and tune them.