From 9a1e310a3c21f886fcad32650d9b1d8526d95c35 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 11 Nov 2026 20:01:41 +0100 Subject: [PATCH 1/3] otlp writes metrics, and the pitfalls OTLP readers disagree about signal=metrics writes what a Java service reports every interval, one collection to a line: gauges, sums, histograms, exponential histograms and summaries named by the OpenTelemetry semantic conventions 1.45.0, chosen by metric_kind, cumulative or delta, with or without exemplars. Every fraction is a whole count of millionths written as a decimal, never a float64, so a value is as wide on every machine and the exponential buckets are a table of microseconds rather than a logarithm. The Collector writes every kind back byte for byte. Ten pitfalls, each off unless chosen: id_encoding, enum_encoding, key_case, large_numbers, bom, time_order, end_before_start, empty_envelope, body and non_finite. Each description says what the OpenTelemetry Collector 0.165.0 does with it, measured in traces, logs and metrics: ids in base64 and an empty batch are dropped without a word, a byte order mark costs the first batch, the rest are read and written back in the spelling the specification asks for. The structural check adds up a file of metrics - collections, series, dates, counters that never fall, buckets that hold the count, exemplars in buckets the collection measured in - and spells a pitfall both ways, so a file has to be broken exactly as its settings say. The oracle gains a second mode for pitfalls: it compares what the Collector wrote with the input in the specification's spelling and expects the batches it drops to be missing. New guards carry canaries of their own. Traces and logs keep their bytes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 26 +- README.md | 4 +- internal/format/otlp/instruments.go | 313 +++++++++++++ internal/format/otlp/layout.go | 136 +++++- internal/format/otlp/logs.go | 83 +++- internal/format/otlp/measure.go | 178 ++++++++ internal/format/otlp/metrics.go | 214 +++++++++ internal/format/otlp/metricwrite.go | 287 ++++++++++++ internal/format/otlp/otlp.go | 20 +- internal/format/otlp/pitfalls.go | 88 ++++ internal/format/otlp/properties.go | 206 +++++++++ internal/format/otlp/records.go | 51 ++- internal/format/otlp/refusals.go | 146 ++++++ internal/format/otlp/settings.go | 347 ++++++++------- internal/format/otlp/spans.go | 16 +- internal/format/otlp/vocabulary.go | 10 +- internal/guard/choiceseed_test.go | 10 + internal/guard/damagewitness_test.go | 6 +- internal/guard/generatorbytes_test.go | 33 +- internal/guard/mutationcoverage_test.go | 9 + internal/guard/otlp_test.go | 174 +++++++- internal/guard/otlpmetrics_test.go | 167 +++++++ internal/guard/parity_test.go | 14 + internal/guard/testdata/generator-golden.json | 45 ++ internal/gui/text/labels.go | 6 +- internal/gui/text/locale/registry/en.json | 297 ++++++++++++- internal/gui/text/locale/registry/pl.json | 69 ++- internal/gui/text/locale/said/en.json | 60 +++ internal/gui/text/locale/said/pl.json | 12 + internal/oracle/otelscripts.go | 126 +++++- internal/oracle/strict.py | 416 ++++++++++++++++-- web/content/ar/site.json | 1 + web/content/cs/site.json | 1 + web/content/de/site.json | 1 + web/content/en/site.json | 1 + web/content/es/site.json | 1 + web/content/fr/site.json | 1 + web/content/hi/site.json | 1 + web/content/id/site.json | 1 + web/content/it/site.json | 1 + web/content/ja/site.json | 1 + web/content/ko/site.json | 1 + web/content/nl/site.json | 1 + web/content/pl/site.json | 1 + web/content/pt-BR/site.json | 1 + web/content/ro/site.json | 1 + web/content/ru/site.json | 1 + web/content/th/site.json | 1 + web/content/tr/site.json | 1 + web/content/uk/site.json | 1 + web/content/vi/site.json | 1 + web/content/zh-Hans/site.json | 1 + web/content/zh-Hant/site.json | 1 + web/public/ar/formats/index.html | 72 ++- web/public/cs/formaty/index.html | 72 ++- web/public/de/formate/index.html | 72 ++- web/public/es/formatos/index.html | 72 ++- web/public/formats/index.html | 72 ++- web/public/fr/formats/index.html | 72 ++- web/public/hi/formats/index.html | 72 ++- web/public/id/format/index.html | 72 ++- web/public/it/formati/index.html | 72 ++- web/public/ja/formats/index.html | 72 ++- web/public/ko/formats/index.html | 72 ++- web/public/nl/formaten/index.html | 72 ++- web/public/pl/formaty/index.html | 72 ++- web/public/pt-br/formatos/index.html | 72 ++- web/public/ro/formate/index.html | 72 ++- web/public/ru/formats/index.html | 72 ++- web/public/th/formats/index.html | 72 ++- web/public/tr/bicimler/index.html | 72 ++- web/public/uk/formats/index.html | 72 ++- web/public/vi/dinh-dang/index.html | 72 ++- web/public/zh-hans/formats/index.html | 72 ++- web/public/zh-hant/formats/index.html | 72 ++- 75 files changed, 4872 insertions(+), 303 deletions(-) create mode 100644 internal/format/otlp/instruments.go create mode 100644 internal/format/otlp/measure.go create mode 100644 internal/format/otlp/metrics.go create mode 100644 internal/format/otlp/metricwrite.go create mode 100644 internal/format/otlp/pitfalls.go create mode 100644 internal/format/otlp/properties.go create mode 100644 internal/format/otlp/refusals.go create mode 100644 internal/guard/otlpmetrics_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ac8f4b0c..574d9d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,15 +27,23 @@ because it turns other people's test suites red. ### Added -- **A new format: `otlp`, OpenTelemetry traces and logs as OTLP JSON.** - `tfg generate --format otlp` writes spans or log records to an exact size, - one batch on each line as the OTLP file exporter writes them (`.jsonl`), or - one request for an OTLP endpoint (`.json`), chosen by `formatting`. Settings - choose `signal`, batch, start time, rate, trace depth, response codes and - severities. A file of lines comes back from the OpenTelemetry Collector byte - for byte, and no line passes 1 MiB, where the Collector's file receiver - silently drops it. `tfg formats --json` names each ending. Metrics are not - written yet. +- **A new format: `otlp`, OpenTelemetry traces, logs and metrics as OTLP + JSON.** `tfg generate --format otlp` writes spans, log records or metrics to + an exact size, one batch on each line as the OTLP file exporter writes them + (`.jsonl`), or one request for an OTLP endpoint (`.json`), chosen by + `formatting`. Metrics are what a Java service reports every `interval`: + gauges, sums, histograms, exponential histograms and summaries, cumulative + or delta. A file comes back from the OpenTelemetry Collector byte for byte, + and no line passes 1 MiB, where the Collector's file receiver silently drops + it. + +- **`otlp` can write the mistakes OTLP readers disagree about.** Ten + settings, each off unless chosen: ids in capitals or base64, enums by + name, keys in snake case, 64-bit numbers without quotes, a byte order mark, + times out of order, spans ending before they start, an empty batch, every + kind of log body, and NaN in a metric. Each description says what the + OpenTelemetry Collector does with it, measured: ids in base64 and an empty + batch vanish without a word, and a byte order mark costs the first batch. - **A file can be sent under a name no disk stores.** `send-as` in a recipe, `--send-as` on the command line and Send as in the window take a name such diff --git a/README.md b/README.md index 2c57e566..e5ffc759 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ owns it, at the exact size you asked for: | ⚙️ **Configuration** | `yaml`, `toml` | | 🗺️ **Maps** | `geojson` (RFC 7946) | | ✉️ **Mail** | `eml` (RFC 5322 with MIME, real files attached) | -| 📡 **Telemetry** | `otlp` (OpenTelemetry traces and logs as OTLP JSON) | +| 📡 **Telemetry** | `otlp` (OpenTelemetry traces, logs and metrics as OTLP JSON) | | 🗜️ **Archives** | `zip`, `targz` (`.tar.gz`) | | 🔊 **Audio** | `wav` | | 🎞️ **Video** | `webm`, `mp4` (both AV1, no sound) | @@ -713,7 +713,7 @@ recipe. `tfg formats ` prints the allowed range or list for each: | `txt`, `md`, `xml` | `encoding`, `bom` | | `json` | `formatting` | | `geojson` | `geometry`, `formatting`, `precision`, `altitude`, `vertices`, `holes`, `winding`, `antimeridian`, `unlocated`, `ids`, `bbox` | -| `otlp` | `signal`, `formatting`, `batch`, `start`, `rate`, `depth`, `status_mix`, `level_mix`, `trace_context` | +| `otlp` | `signal`, `formatting`, `batch`, `start`, `rate`, `depth`, `status_mix`, `level_mix`, `trace_context`, `metric_kind`, `temporality`, `exemplars`, `interval`, `id_encoding`, `enum_encoding`, `key_case`, `large_numbers`, `bom`, `time_order`, `end_before_start`, `empty_envelope`, `body`, `non_finite` | | `eml` | `body`, `layout`, `text_encoding`, `charset`, `headers`, `recipients`, `line_endings`, `attachments`, `attachment_format`, `attachment_size`, `filename_style`, `message_depth`, `inline`, `inline_format`, `inline_size` | | `svg` | `width`, `height` | | `html` | `structure`, `nesting`, `characters`, `elements`, `links`, `bom`, `formatting`, `styles`, `inline`, `inline_format`, `inline_size` | diff --git a/internal/format/otlp/instruments.go b/internal/format/otlp/instruments.go new file mode 100644 index 00000000..e29dff62 --- /dev/null +++ b/internal/format/otlp/instruments.go @@ -0,0 +1,313 @@ +package otlp + +// What a file of metrics measures: the instruments a Java service exports +// beside the requests it serves. Every name, unit and attribute but two is +// marked stable in version 1.45.0 of the semantic conventions, read on +// 2026-11-11 from docs/http/http-metrics.md and docs/runtime/jvm-metrics.md. +// The conventions name no stable gauge of whole numbers, so the queue of +// orders is the service's own, under its own prefix. The summary is what +// Micrometer, the metrics library of Spring, writes for a timer with +// percentiles, because no convention defines one. + +// shape is the kind of data a metric carries, as metrics.proto names the +// member that holds it. +type shape int + +const ( + shapeGauge shape = iota + shapeSum + shapeHistogram + shapeExponential + shapeSummary +) + +// member is the name of the object that holds the data points of each shape. +var member = [...]string{ + shapeGauge: "gauge", + shapeSum: "sum", + shapeHistogram: "histogram", + shapeExponential: "exponentialHistogram", + shapeSummary: "summary", +} + +// temporal says whether a shape states its aggregation temporality. A gauge +// and a summary have none. +func (sh shape) temporal() bool { + return sh == shapeSum || sh == shapeHistogram || sh == shapeExponential +} + +// The aggregation temporalities as metrics.proto numbers them. +const ( + temporalityDelta = 1 + temporalityCumulative = 2 +) + +// temporalityNames are the names metrics.proto gives the two, for a file that +// spells an enum by its name. +var temporalityNames = map[int64]string{ + temporalityDelta: "AGGREGATION_TEMPORALITY_DELTA", + temporalityCumulative: "AGGREGATION_TEMPORALITY_CUMULATIVE", +} + +// values is what one instrument measures, which decides how its points are +// drawn. +type values int + +const ( + cpuUtilization values = iota + ordersPending + cpuTime + classesLoaded + memoryUsed + requestDuration + gcDuration + requestTimer +) + +// instrument is one metric a file holds in every collection. +type instrument struct { + name, description, unit string + shape shape + values values + // whole says the points carry a whole number, asInt, rather than asDouble. + // For a gauge and a sum only. + whole bool + // monotonic is a sum that only grows. + monotonic bool + // updown is an UpDownCounter, which stays cumulative when delta is asked: + // the delta preference of an SDK keeps every UpDownCounter cumulative, + // sdk_exporters/otlp.md of the specification, version 1.62.0. + updown bool + // exemplary is measured inside a request, which is what lets a point of + // it name the trace it was measured in. The jvm instruments are read by a + // callback when the collection runs, outside every span. + exemplary bool + series []series +} + +// series is one set of attributes an instrument reports a point for, in the +// order the conventions list them. +type series []attribute + +// attribute is one attribute of a series: text, or a whole number. +type attribute struct { + key, text string + number int64 +} + +// whole says the attribute holds a number. Every number this vocabulary uses +// is above nought. +func (a attribute) whole() bool { return a.number != 0 } + +// httpSeries are the requests the duration of every request is reported for: +// the routes of the trace vocabulary with the code each usually ends in, and +// three that end otherwise. The order of the attributes is the one the table +// of http.server.request.duration gives. +var httpSeries = func() []series { + type ended struct { + request request + status int64 + } + var all []ended + for _, q := range requests { + code := int64(200) + switch q.method { + case "POST": + code = 201 + if q.route == "/login" { + code = 200 + } + case "DELETE": + code = 204 + } + all = append(all, ended{q, code}) + } + all = append(all, + ended{request{"POST", "/login", "/login"}, 401}, + ended{request{"GET", "/api/v1/orders/{id}", "/api/v1/orders/4711"}, 404}, + ended{request{"POST", "/api/v1/orders", "/api/v1/orders"}, 503}, + ) + out := make([]series, 0, len(all)) + for _, x := range all { + s := series{{key: "http.request.method", text: x.request.method}, {key: "url.scheme", text: "https"}} + if x.status >= serverError { + s = append(s, attribute{key: "error.type", text: statusText(x.status)}) + } + s = append(s, attribute{key: "http.response.status_code", number: x.status}, attribute{key: "http.route", text: x.request.route}) + out = append(out, s) + } + return out +}() + +// timerSeries are the same requests as Micrometer tags them: every tag is +// text, and they come sorted by name. +var timerSeries = func() []series { + out := make([]series, 0, len(httpSeries)) + for _, s := range httpSeries { + var method, route string + var code int64 + for _, a := range s { + switch a.key { + case "http.request.method": + method = a.text + case "http.route": + route = a.text + case "http.response.status_code": + code = a.number + } + } + outcome := "SUCCESS" + switch { + case code >= serverError: + outcome = "SERVER_ERROR" + case code >= 400: + outcome = "CLIENT_ERROR" + } + out = append(out, series{ + {key: "exception", text: "none"}, {key: "method", text: method}, {key: "outcome", text: outcome}, + {key: "status", text: statusText(code)}, {key: "uri", text: route}, + }) + } + return out +}() + +// statusText is a response code as text, built once while the vocabulary is. +func statusText(code int64) string { + return string([]byte{byte('0' + code/100), byte('0' + code/10%10), byte('0' + code%10)}) +} + +var gcSeries = []series{ + {{key: "jvm.gc.action", text: "end of minor GC"}, {key: "jvm.gc.name", text: "G1 Young Generation"}}, + {{key: "jvm.gc.action", text: "end of major GC"}, {key: "jvm.gc.name", text: "G1 Old Generation"}}, +} + +// memoryPools are the pools jvm.memory.used reports, each with the range its +// use moves in, in bytes. +var memoryPools = []struct { + series series + low int64 + high int64 +}{ + {series{{key: "jvm.memory.pool.name", text: "G1 Eden Space"}, {key: "jvm.memory.type", text: "heap"}}, 16 << 20, 256 << 20}, + {series{{key: "jvm.memory.pool.name", text: "G1 Old Gen"}, {key: "jvm.memory.type", text: "heap"}}, 64 << 20, 1 << 30}, + {series{{key: "jvm.memory.pool.name", text: "Metaspace"}, {key: "jvm.memory.type", text: "non_heap"}}, 48 << 20, 80 << 20}, +} + +var ( + instCPUUtilization = &instrument{ + name: "jvm.cpu.recent_utilization", description: "Recent CPU utilization for the process as reported by the JVM.", unit: "1", + shape: shapeGauge, values: cpuUtilization, series: []series{nil}, + } + instOrdersPending = &instrument{ + name: "shop.orders.pending", description: "Orders accepted and not yet shipped.", unit: "{order}", + shape: shapeGauge, values: ordersPending, whole: true, series: []series{nil}, + } + instCPUTime = &instrument{ + name: "jvm.cpu.time", description: "CPU time used by the process as reported by the JVM.", unit: "s", + shape: shapeSum, values: cpuTime, monotonic: true, series: []series{nil}, + } + instClassesLoaded = &instrument{ + name: "jvm.class.loaded", description: "Number of classes loaded since JVM start.", unit: "{class}", + shape: shapeSum, values: classesLoaded, whole: true, monotonic: true, series: []series{nil}, + } + instMemoryUsed = &instrument{ + name: "jvm.memory.used", description: "Measure of memory used.", unit: "By", + shape: shapeSum, values: memoryUsed, whole: true, updown: true, + series: []series{memoryPools[0].series, memoryPools[1].series, memoryPools[2].series}, + } + instRequestDuration = &instrument{ + name: "http.server.request.duration", description: "Duration of HTTP server requests.", unit: "s", + shape: shapeHistogram, values: requestDuration, exemplary: true, series: httpSeries, + } + instGCDuration = &instrument{ + name: "jvm.gc.duration", description: "Duration of JVM garbage collection actions.", unit: "s", + shape: shapeExponential, values: gcDuration, series: gcSeries, + } + instRequestTimer = &instrument{ + name: "http.server.requests", description: "Duration of HTTP server request handling.", unit: "s", + shape: shapeSummary, values: requestTimer, series: timerSeries, + } +) + +// kinds are the instruments each metric_kind writes in every collection, in +// that order. +var kinds = map[string][]*instrument{ + Gauge: {instCPUUtilization, instOrdersPending}, + Sum: {instCPUTime, instClassesLoaded, instMemoryUsed}, + Histogram: {instRequestDuration}, + ExponentialHistogram: {instGCDuration}, + Summary: {instRequestTimer}, + Mixed: {instCPUUtilization, instOrdersPending, instCPUTime, instClassesLoaded, instMemoryUsed, + instRequestDuration, instGCDuration, instRequestTimer}, +} + +// requestBounds are the bucket boundaries the conventions ask +// http.server.request.duration to be recorded with, in millionths of a +// second. +var requestBounds = []micros{5000, 10000, 25000, 50000, 75000, 100000, 250000, 500000, 750000, + 1_000_000, 2_500_000, 5_000_000, 7_500_000, 10_000_000} + +// The exponential histogram of a GC duration, at scale 3: eight buckets to +// every doubling. A duration lies between a millisecond and two seconds, which +// the 88 buckets from index -80 to 7 hold - fewer than the 160 an SDK keeps by +// default, so an SDK would not scale down further. +const ( + exponentialScale = 3 + firstBucket = -80 + bucketCount = 88 +) + +// bucketTops is where each exponential bucket ends, from index -80 up: the +// largest whole number of microseconds not above 2^((i+1)/8) seconds. A +// duration is a whole number of microseconds, so lying at or below the value +// in this table is lying at or below the bound itself. Written out rather +// than computed, because computing it takes a logarithm in floating point. A +// guard checks each entry against the eighth power of the bound in exact +// arithmetic. +var bucketTops = [bucketCount]micros{ + 1064, 1161, 1266, 1381, 1506, 1642, 1791, 1953, + 2129, 2322, 2532, 2762, 3012, 3284, 3582, 3906, + 4259, 4645, 5065, 5524, 6024, 6569, 7164, 7812, + 8519, 9290, 10131, 11048, 12048, 13139, 14328, 15625, + 17039, 18581, 20263, 22097, 24097, 26278, 28656, 31250, + 34078, 37162, 40526, 44194, 48194, 52556, 57312, 62500, + 68156, 74325, 81052, 88388, 96388, 105112, 114625, 125000, + 136313, 148650, 162104, 176776, 192776, 210224, 229251, 250000, + 272626, 297301, 324209, 353553, 385552, 420448, 458502, 500000, + 545253, 594603, 648419, 707106, 771105, 840896, 917004, 1000000, + 1090507, 1189207, 1296839, 1414213, 1542210, 1681792, 1834008, 2000000, +} + +// BucketTops is the table above with the index of its first bucket and the +// scale, for the guard that checks it. +func BucketTops() (scale, first int, tops []int64) { + return exponentialScale, firstBucket, bucketTops[:] +} + +// band is a range durations are drawn from, in microseconds. Repeats in a +// list are the weighting. +type band struct{ low, high micros } + +var ( + // requestBands: most requests are quick, some slow, few very slow - from a + // millisecond to two seconds, the range the exponential table holds. + requestBands = []band{{1000, 20000}, {1000, 20000}, {1000, 20000}, {1000, 20000}, + {20000, 150000}, {20000, 150000}, {20000, 150000}, {150000, 800000}, {150000, 800000}, {800000, 2_000_000}} + // gcBands by action: a young collection pauses briefly and an old one + // longer. + gcBands = [][]band{ + {{1000, 8000}, {1000, 8000}, {8000, 50000}}, + {{50000, 300000}, {50000, 300000}, {300000, 2_000_000}}, + } +) + +// longestDuration is the longest duration any band draws. +const longestDuration micros = 2_000_000 + +// timerQuantiles are the percentiles the summary reports, in millionths. +var timerQuantiles = []micros{500000, 950000, 990000} + +// mostMeasurements is how many measurements a series can take between two +// collections. Few, so a closing point stays short and every count of the file +// grows by a bounded step. +const mostMeasurements = 3 diff --git a/internal/format/otlp/layout.go b/internal/format/otlp/layout.go index 9658b8c3..f86950e4 100644 --- a/internal/format/otlp/layout.go +++ b/internal/format/otlp/layout.go @@ -1,6 +1,7 @@ package otlp import ( + "encoding/base64" "encoding/hex" "strconv" ) @@ -38,8 +39,9 @@ type envelope struct { } var envelopes = map[string]envelope{ - Traces: {"resourceSpans", "scopeSpans", "spans"}, - Logs: {"resourceLogs", "scopeLogs", "logRecords"}, + Traces: {"resourceSpans", "scopeSpans", "spans"}, + Logs: {"resourceLogs", "scopeLogs", "logRecords"}, + Metrics: {"resourceMetrics", "scopeMetrics", "metrics"}, } const ( @@ -66,6 +68,25 @@ type emitter struct { // fresh is true right after a container opens, before its first element, // which is the one element not preceded by a comma. fresh bool + sp spelling +} + +// spelling is how the tokens of a file are spelled where the pitfalls change +// them. The zero value is the spelling of the specification, the one the +// Collector writes back. +type spelling struct { + // ids is upper-hex or base64 where the ids are not hex in lower case. + ids string + // names writes an enum as its name, which the specification forbids. + names bool + // snake writes every key in snake case, which the specification forbids. + snake bool + // bare writes a number of 64 bits as a JSON number rather than as text. + bare bool +} + +func (s settings) spelling() spelling { + return spelling{ids: s.idEncoding, names: s.enumNames, snake: s.snakeKeys, bare: s.bareNumbers} } // indentation is enough spaces for the deepest token a record has: the value @@ -109,13 +130,40 @@ func (e *emitter) close(c byte) { func (e *emitter) key(k string) { e.next() e.b = append(e.b, '"') - e.b = append(e.b, k...) + if e.sp.snake { + e.b = appendSnake(e.b, k) + } else { + e.b = append(e.b, k...) + } e.b = append(e.b, '"', ':') if e.indent { e.b = append(e.b, ' ') } } +// appendSnake appends a key in snake case: resourceSpans as resource_spans, +// the name the .proto file gives the field and the specification calls not +// valid as a key. +func appendSnake(dst []byte, k string) []byte { + for i := 0; i < len(k); i++ { + if c := k[i]; 'A' <= c && c <= 'Z' { + dst = append(dst, '_', c-'A'+'a') + continue + } + dst = append(dst, k[i]) + } + return dst +} + +// enum is a value of an enum: its number, or its name where the pitfall asks. +func (e *emitter) enum(number int64, name string) { + if e.sp.names { + e.text(name) + return + } + e.whole(number) +} + // text is a string value. Every string this format writes comes from its own // vocabulary, none of which needs escaping. func (e *emitter) text(v string) { @@ -127,8 +175,15 @@ func (e *emitter) text(v string) { func (e *emitter) whole(v int64) { e.b = strconv.AppendInt(e.b, v, 10) } // quoted is a whole number written as text, which is how the JSON encoding of -// OTLP writes every number of 64 bits, a time among them. -func (e *emitter) quoted(v int64) { e.numbered("", v) } +// OTLP writes every number of 64 bits, a time among them - or as a JSON +// number where the pitfall asks, which a reader is told to take as well. +func (e *emitter) quoted(v int64) { + if e.sp.bare { + e.whole(v) + return + } + e.numbered("", v) +} // numbered is a string value made of a prefix and a whole number, appended // straight into the file. @@ -147,15 +202,76 @@ func (e *emitter) numbered(prefix string, v int64) { // nothing is an object with no member, written the same in every layout. func (e *emitter) nothing() { e.b = append(e.b, '{', '}') } +// truth is the one boolean this format writes. A false one is left out, which +// is how the Collector writes it. +func (e *emitter) truth() { e.b = append(e.b, "true"...) } + +// micros is how many millionths of a unit a decimal value is. Every fraction +// this format writes is counted that way and never held as a float64. +// +// Two reasons. math.Log and its kin run assembly on amd64 and Go elsewhere, +// and a result one unit of the last place apart would put a duration in the +// next bucket on one machine only, which D11 forbids. And a whole count of +// millionths below 2^33 * 10^6 is written exactly as the Collector writes the +// float64 it reads back: it has six decimals at most, and below 2^33 two +// float64 values lie closer than a millionth, so no shorter decimal names the +// same one - measured with tools/probes/otlp/metrics-canonical.py, 2026-11-11. +type micros = int64 + +// microCeiling is the first count of millionths that rule no longer covers. +const microCeiling micros = (1 << 33) * 1_000_000 + +// decimal writes a count of millionths as the shortest decimal that names it: +// the whole part, then the fraction without the zeros it ends in, and no point +// at all for a whole number - 100, 0.5, 0.000125. +func (e *emitter) decimal(v micros) { + e.whole(v / 1_000_000) + frac := v % 1_000_000 + if frac == 0 { + return + } + var digits [6]byte + for i := 5; i >= 0; i-- { + digits[i] = byte('0' + frac%10) + frac /= 10 + } + n := 6 + for digits[n-1] == '0' { + n-- + } + e.b = append(e.b, '.') + e.b = append(e.b, digits[:n]...) +} + // id is a trace id or a span id, two hex digits a byte. Lower case because // that is what the Collector writes back, though the specification takes // either. func (e *emitter) id(id []byte) { e.b = append(e.b, '"') - e.b = hex.AppendEncode(e.b, id) + switch e.sp.ids { + case UpperHex: + at := len(e.b) + e.b = hex.AppendEncode(e.b, id) + capitals(e.b[at:]) + case Base64: + // What the JSON mapping of protobuf writes for bytes, and what the + // OTLP specification says the ids are not. + e.b = base64.StdEncoding.AppendEncode(e.b, id) + default: + e.b = hex.AppendEncode(e.b, id) + } e.b = append(e.b, '"') } +// capitals turns the letters of hex digits into capitals, in place. +func capitals(digits []byte) { + for i, c := range digits { + if 'a' <= c && c <= 'f' { + digits[i] = c - 'a' + 'A' + } + } +} + // attribute opens one attribute through the brace of its value. func (e *emitter) attribute(key string) { e.next() @@ -213,8 +329,8 @@ func (e *emitter) padding() (at int) { // opening is what starts a batch of this kind of telemetry, through the // bracket that opens its records: the resource the records come from and the // scope that wrote them. -func opening(names envelope, indent bool) []byte { - e := &emitter{indent: indent} +func opening(names envelope, indent bool, sp spelling) []byte { + e := &emitter{indent: indent, sp: sp} e.open('{') e.key(names.resources) e.open('[') @@ -247,8 +363,8 @@ func opening(names envelope, indent bool) []byte { // closing is what ends a batch, from the bracket that closes its records. The // schema address follows the records it describes at both levels, which is // where the Collector writes it. -func closing(l layout) []byte { - e := &emitter{indent: l.indent, depth: recordDepth} +func closing(l layout, sp spelling) []byte { + e := &emitter{indent: l.indent, depth: recordDepth, sp: sp} e.close(']') e.key("schemaUrl") e.text(schemaURL) diff --git a/internal/format/otlp/logs.go b/internal/format/otlp/logs.go index 8645cf71..c93fb887 100644 --- a/internal/format/otlp/logs.go +++ b/internal/format/otlp/logs.go @@ -1,6 +1,8 @@ package otlp import ( + "encoding/base64" + // D11 promises the same bytes from the same seed, so a deliberate, // reproducible generator is the product rather than a weakness. Nothing // here ever makes a secret. @@ -22,6 +24,8 @@ type entry struct { trace traceID span spanID + // body is which kind of value the body holds, nought for text. + body int } // drawEntry draws the next log record of the file. n is how many came before @@ -33,12 +37,15 @@ type entry struct { func (r *records) drawEntry(rng *rand.Rand, n int64) entry { lv := r.s.levels[rng.IntN(len(r.s.levels))] en := entry{ - at: r.s.start + n*r.s.step(), + at: r.s.start + r.s.order(n)*r.s.step(), level: lv, message: lv.messages[rng.IntN(len(lv.messages))], function: functions[rng.IntN(len(functions))], line: firstLine + rng.Int64N(lastLine-firstLine+1), } + if r.s.anyBody { + en.body = int(n % bodyKinds) + } drawID(en.trace[:], rng) drawID(en.span[:], rng) return en @@ -52,8 +59,14 @@ func (r *records) widestEntries() []entry { return nil } var out []entry + kinds := 1 + if r.s.anyBody { + kinds = bodyKinds + } for _, lv := range r.s.levels { - out = append(out, entry{at: r.s.start, level: lv, message: longest(lv.messages), function: longest(functions), line: lastLine}) + for body := range kinds { + out = append(out, entry{at: r.s.start, level: lv, message: longest(lv.messages), function: longest(functions), line: lastLine, body: body}) + } } return out } @@ -71,13 +84,12 @@ func (r *records) writeEntry(en entry, padded bool) (at int) { e.key("observedTimeUnixNano") e.quoted(en.at) e.key("severityNumber") - e.whole(en.level.number) + e.enum(en.level.number, en.level.enum) e.key("severityText") e.text(en.level.text) e.key("body") e.open('{') - e.key("stringValue") - e.text(en.message) + r.writeBody(en) e.close('}') e.key("attributes") e.open('[') @@ -100,3 +112,64 @@ func (r *records) writeEntry(en entry, padded bool) (at int) { e.close('}') return at } + +// bodyKinds is how many kinds of value AnyValue has, which body any-value +// takes in turn. +const bodyKinds = 7 + +// beyondDoubles is the smallest whole number a float64 cannot hold, 2^53 + 1. +// A reader that turns every JSON number into a double reads it one less. +const beyondDoubles = 1<<53 + 1 + +// writeBody writes what a log record says as the kind of value its body +// holds: text, or with body any-value each of the seven kinds of AnyValue in +// turn - text, a boolean, a whole number past what a double holds, a +// fraction, a list, a map and bytes. +func (r *records) writeBody(en entry) { + e := &r.e + switch en.body { + case 0: + e.key("stringValue") + e.text(en.message) + case 1: + e.key("boolValue") + e.truth() + case 2: + e.key("intValue") + e.quoted(beyondDoubles) + case 3: + e.key("doubleValue") + e.decimal(en.line * 1000) + case 4: + e.key("arrayValue") + e.open('{') + e.key("values") + e.open('[') + e.next() + e.open('{') + e.key("stringValue") + e.text(en.message) + e.close('}') + e.next() + e.open('{') + e.key("intValue") + e.quoted(en.line) + e.close('}') + e.close(']') + e.close('}') + case 5: + e.key("kvlistValue") + e.open('{') + e.key("values") + e.open('[') + e.textAttribute("message", en.message) + e.wholeAttribute("code.line.number", en.line) + e.close(']') + e.close('}') + default: + e.key("bytesValue") + e.b = append(e.b, '"') + e.b = base64.StdEncoding.AppendEncode(e.b, []byte(en.message)) + e.b = append(e.b, '"') + } +} diff --git a/internal/format/otlp/measure.go b/internal/format/otlp/measure.go new file mode 100644 index 00000000..62a3f1b1 --- /dev/null +++ b/internal/format/otlp/measure.go @@ -0,0 +1,178 @@ +package otlp + +import ( + // D11 promises the same bytes from the same seed, so a deliberate, + // reproducible generator is the product rather than a weakness. Nothing + // here ever makes a secret. + // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used + "math/rand/v2" +) + +// measure draws the durations a series measured since the collection before +// and folds them into its point: the buckets, the count, the sum, the least +// and the most, and for a summary the quantiles of this collection. +func (m *metricPlan) measure(rng *rand.Rand, p *point, st *seriesState, cumulative bool) { + inst := p.slot.inst + bands := requestBands + if inst.values == gcDuration { + bands = gcBands[p.slot.series] + } + n := 1 + rng.IntN(mostMeasurements) + var taken [mostMeasurements]micros + var where [mostMeasurements]int + m.cycle = [bucketCount]int64{} + for i := range n { + b := bands[rng.IntN(len(bands))] + taken[i] = b.low + rng.Int64N(b.high-b.low+1) + if inst.exemplary { + ex := &p.exemplars[i] + ex.at = p.at - rng.Int64N(m.s.step()) + ex.value = taken[i] + drawID(ex.trace[:], rng) + drawID(ex.span[:], rng) + } + where[i] = bucketOf(inst.shape, taken[i]) + m.cycle[where[i]]++ + } + p.count, p.sum, p.least, p.most = tally(taken[:n]) + st.fold(&m.cycle, p.count, p.sum, p.least, p.most) + held := m.cycle[:] + if cumulative { + held = st.buckets[:] + p.count, p.sum, p.least, p.most = st.count, st.sum, st.least, st.most + } + m.shapePoint(p, st, held, where[:n], taken[:n]) +} + +// tally is how many durations there are, their sum, the least and the most. +func tally(taken []micros) (count int64, sum, least, most micros) { + least = taken[0] + for _, d := range taken { + sum += d + least = min(least, d) + most = max(most, d) + } + return int64(len(taken)), sum, least, most +} + +// fold adds what one collection measured to what the series has measured +// since the process started. +func (st *seriesState) fold(cycle *[bucketCount]int64, count int64, sum, least, most micros) { + if st.count == 0 || least < st.least { + st.least = least + } + st.most = max(st.most, most) + st.count += count + st.sum += sum + for i := range cycle { + st.buckets[i] += cycle[i] + } +} + +// shapePoint gives a point what its shape writes beside the count and the +// sum: the buckets an explicit histogram writes all of and an exponential one +// writes from its first count to its last, the exemplars, or the quantiles. +func (m *metricPlan) shapePoint(p *point, st *seriesState, held []int64, where []int, taken []micros) { + switch p.slot.inst.shape { + case shapeHistogram: + p.buckets = held[:len(requestBounds)+1] + if m.s.exemplars { + p.examples = byBucket(p, where) + } + case shapeExponential: + first, last := 0, len(held)-1 + for held[first] == 0 { + first++ + } + for held[last] == 0 { + last-- + } + p.buckets, p.offset = held[first:last+1], firstBucket+first + case shapeSummary: + // The summary is cumulative in its count and its sum and speaks of + // this collection alone in its quantiles, the way Micrometer and the + // Prometheus client write one. + p.count, p.sum = st.count, st.sum + quantiles(p, taken) + } +} + +// quantiles are the percentiles of what one collection measured, each the +// nearest rank: the smallest measurement at or above that share of them. +func quantiles(p *point, taken []micros) { + for i := 1; i < len(taken); i++ { + for j := i; j > 0 && taken[j] < taken[j-1]; j-- { + taken[j], taken[j-1] = taken[j-1], taken[j] + } + } + for i, q := range timerQuantiles { + rank := (q*int64(len(taken)) + 999_999) / 1_000_000 + p.quantiles[i] = taken[max(rank, 1)-1] + } +} + +// bucketOf is the bucket a duration falls in: the first whose upper bound it +// does not pass, the way an explicit histogram and an exponential one both +// count a value equal to a bound. +func bucketOf(sh shape, d micros) int { + if sh == shapeHistogram { + return explicitBucket(d) + } + return exponentialBucket(d) +} + +func explicitBucket(d micros) int { + for i, top := range requestBounds { + if d <= top { + return i + } + } + return len(requestBounds) +} + +// exponentialBucket finds the bucket by halving the table, which is sorted. +func exponentialBucket(d micros) int { + low, high := 0, bucketCount-1 + for low < high { + mid := (low + high) / 2 + if d <= bucketTops[mid] { + high = mid + } else { + low = mid + 1 + } + } + return low +} + +// byBucket keeps one exemplar for every bucket the collection measured in - +// the last measurement that fell there, as the reservoir of an explicit +// histogram keeps them - in the order of the buckets, and says how many. +func byBucket(p *point, where []int) int { + kept := 0 + var keptBucket [mostMeasurements]int + for i := range where { + at := indexOf(keptBucket[:kept], where[i]) + if at < 0 { + at, kept = kept, kept+1 + } + keptBucket[at] = where[i] + p.exemplars[at] = p.exemplars[i] + } + for i := 1; i < kept; i++ { + for j := i; j > 0 && keptBucket[j] < keptBucket[j-1]; j-- { + keptBucket[j], keptBucket[j-1] = keptBucket[j-1], keptBucket[j] + p.exemplars[j], p.exemplars[j-1] = p.exemplars[j-1], p.exemplars[j] + } + } + return kept +} + +// indexOf is where a bucket stands among those kept, -1 when it is not there. +func indexOf(kept []int, bucket int) int { + for k, b := range kept { + if b == bucket { + return k + } + } + return -1 +} diff --git a/internal/format/otlp/metrics.go b/internal/format/otlp/metrics.go new file mode 100644 index 00000000..cf07a75e --- /dev/null +++ b/internal/format/otlp/metrics.go @@ -0,0 +1,214 @@ +package otlp + +import ( + // D11 promises the same bytes from the same seed, so a deliberate, + // reproducible generator is the product rather than a weakness. Nothing + // here ever makes a secret. + // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used + "math/rand/v2" +) + +// A file of metrics is a run of collections, the way the periodic reader of an +// SDK exports them: every interval, one point for every series of every +// instrument, sent together. In the default layout a line is one collection. +// +// The record the filler counts is one point. The first point of a metric in a +// collection carries the name, the unit and the kind in front of it, and the +// last one what closes the metric, the same way a span carries what opens and +// closes its batch. + +// pointDepth is how deep a point sits: inside the metric, the member that +// holds its data and the array of points. +const pointDepth = recordDepth + 3 + +// widestCount bounds every count a file holds. A series takes three +// measurements at most between two collections and a collection comes a +// second apart at the least, so from the first year a file may start in to +// the last moment it may name a count stays below 2.5e10 - eleven digits. +const widestCount = 99_999_999_999 + +// slot is one point of a collection: the instrument, which of its series, +// and whether it is the last series, the point that closes the metric. +type slot struct { + inst *instrument + series int + last bool +} + +// seriesState is what a series remembers from one collection to the next. +// Arrays rather than slices, so the state of the one series a thrown away +// point changed is put back by copying a value. +type seriesState struct { + // running is the count a counter has reached: microseconds of CPU, or + // classes loaded. + running int64 + count int64 + sum micros + least, most micros + buckets [bucketCount]int64 +} + +// exemplar is one measurement a point of a histogram names, with the trace it +// was taken in. +type exemplar struct { + at int64 + value micros + trace traceID + span spanID +} + +// point is everything one data point says, drawn or chosen in one place so the +// smallest file is measured with the same code that writes every other one. +type point struct { + slot slot + start, at int64 + // number is the value of a gauge or a sum: a whole number, or millionths + // where the instrument writes asDouble. + number int64 + count int64 + sum micros + least, most micros + // buckets are the counts written, from the first bucket that holds any. + buckets []int64 + // offset is the index of the first exponential bucket written. + offset int + quantiles [3]micros + exemplars [mostMeasurements]exemplar + examples int + // temporality is the number written, nought for a shape without one. + temporality int64 +} + +// metricPlan is how a file of metrics is laid out and what it remembers. +type metricPlan struct { + s settings + slots []slot + states []seriesState + // saved is the state the last point changed, as it was before, so the + // point thrown away to measure it can be undone. + saved seriesState + savedAt int + // cycle holds the buckets of one collection, scratch reused for every + // point. + cycle [bucketCount]int64 + cur point +} + +func newMetricPlan(s settings) *metricPlan { + m := &metricPlan{s: s} + for _, inst := range s.instruments { + for i := range inst.series { + m.slots = append(m.slots, slot{inst: inst, series: i, last: i == len(inst.series)-1}) + } + } + m.states = make([]seriesState, len(m.slots)) + return m +} + +// cumulative says whether a point of this instrument covers everything since +// the process started rather than the time since the collection before. +func (s settings) cumulative(inst *instrument) bool { + return s.temporality == Cumulative || inst.updown || !inst.shape.temporal() +} + +// temporalityOf is the number a metric of this instrument states, nought when +// its shape states none. +func (s settings) temporalityOf(inst *instrument) int64 { + switch { + case !inst.shape.temporal(): + return 0 + case s.cumulative(inst): + return temporalityCumulative + default: + return temporalityDelta + } +} + +// draw draws the point a file holds after count others and moves its series on. +// +// The order of the draws is part of the format, for the reason drawSpan gives. +// The trace and the span of every measurement of a request are drawn whether +// or not the file writes exemplars, so turning them on changes nothing else. +func (m *metricPlan) draw(rng *rand.Rand, count int64) *point { + s := m.s + at := int(count % int64(len(m.slots))) + collection := count / int64(len(m.slots)) + sl := m.slots[at] + st := &m.states[at] + m.saved, m.savedAt = *st, at + + p := &m.cur + // Dated by its place on the clock, which time_order may swap with the + // collection next to it. What it measured is drawn in the order written. + tick := s.order(collection) + *p = point{slot: sl, at: s.start + (tick+1)*s.step(), temporality: s.temporalityOf(sl.inst)} + cumulative := s.cumulative(sl.inst) + p.start = s.start + if !cumulative { + p.start = s.start + tick*s.step() + } + switch sl.inst.values { + case cpuUtilization: + p.number = utilization(rng) + if s.nonFinite { + p.number = nonFiniteAt(collection, p.number) + } + case ordersPending: + p.number = rng.Int64N(251) + case cpuTime: + // A share of one processor over the interval, in microseconds. + used := utilization(rng) * s.interval + st.running += used + p.number = pick(cumulative, st.running, used) + case classesLoaded: + loaded := rng.Int64N(4) + if collection == 0 { + loaded = 4000 + rng.Int64N(1000) + } + st.running += loaded + p.number = pick(cumulative, st.running, loaded) + case memoryUsed: + pool := memoryPools[sl.series] + p.number = pool.low + rng.Int64N(pool.high-pool.low+1) + default: + m.measure(rng, p, st, cumulative) + } + return p +} + +func utilization(rng *rand.Rand) micros { return 1000 + rng.Int64N(949_000) } + +func pick(cumulative bool, running, step int64) int64 { + if cumulative { + return running + } + return step +} + +// The values that are not numbers, as the JSON mapping of protobuf writes a +// double that holds one: text, which the OTLP specification takes as it is. +// A point holds one as the negative of its place here, a number no +// utilization can be. +const ( + notANumber = 1 + positiveInfinity = 2 + negativeInfinity = 3 +) + +var nonFiniteNames = [...]string{notANumber: "NaN", positiveInfinity: "Infinity", negativeInfinity: "-Infinity"} + +// nonFiniteAt is the utilization a collection reports with non_finite on: +// NaN in the second of every four, Infinity and -Infinity in turn in the +// fourth, and what was measured in the other two. +func nonFiniteAt(collection int64, measured micros) micros { + switch collection % 4 { + case 1: + return -notANumber + case 3: + if collection/4%2 == 0 { + return -positiveInfinity + } + return -negativeInfinity + } + return measured +} diff --git a/internal/format/otlp/metricwrite.go b/internal/format/otlp/metricwrite.go new file mode 100644 index 00000000..e3608f4f --- /dev/null +++ b/internal/format/otlp/metricwrite.go @@ -0,0 +1,287 @@ +package otlp + +// widest are the longest points each instrument of the settings can +// write, every series of it, with every count at the most a file can reach +// and every fraction at its longest. None when the file holds no metrics. +// +// Dated at the start of the file, for the reason widestSpans gives. +func (m *metricPlan) widest() []point { + var out []point + wide := make([]int64, bucketCount) + for i := range wide { + wide[i] = widestCount + } + for _, sl := range m.slots { + out = append(out, m.widestPoint(sl, wide)) + } + return out +} + +// widestPoint is the longest point one series can write: every count at the +// most a file can reach, every fraction at its longest, the widest value its +// instrument draws. +func (m *metricPlan) widestPoint(sl slot, wide []int64) point { + inst := sl.inst + p := point{slot: sl, start: m.s.start, at: m.s.start, temporality: m.s.temporalityOf(inst), + count: widestCount, sum: microCeiling - 1, least: longestDuration - 1, most: longestDuration - 1} + switch inst.values { + case cpuUtilization: + p.number = 949_999 + if m.s.nonFinite { + p.number = -negativeInfinity + } + case ordersPending: + p.number = 250 + case cpuTime: + p.number = microCeiling - 1 + case classesLoaded: + p.number = widestCount + case memoryUsed: + p.number = memoryPools[sl.series].high + } + switch inst.shape { + case shapeHistogram: + p.buckets = wide[:len(requestBounds)+1] + if m.s.exemplars { + m.widestExemplars(&p) + } + case shapeExponential: + p.buckets, p.offset = wide, firstBucket + case shapeSummary: + p.quantiles = [3]micros{longestDuration - 1, longestDuration - 1, longestDuration - 1} + } + return p +} + +// widestExemplars gives a point as many exemplars as a collection can keep, +// each with the longest value. +func (m *metricPlan) widestExemplars(p *point) { + p.examples = mostMeasurements + for i := range p.exemplars { + p.exemplars[i].at, p.exemplars[i].value = m.s.start, longestDuration-1 + } +} + +// pointWriter writes the points of metrics with the emitter of a file: +// apart from records so the writing of one shape stays beside the others +// and the builder of records keeps to building. +type pointWriter struct{ e *emitter } + +// write writes one point, after the name of its metric when opens says so +// and before what closes the metric when closes does. padded adds the padding +// attribute. at is where the padding begins, nought without one. +func (w pointWriter) write(p *point, opens, padded, closes bool) (at int) { + e, inst := w.e, p.slot.inst + if opens { + w.openMetric(inst) + } + e.next() + e.open('{') + at = w.writeSeries(inst.series[p.slot.series], padded) + e.key("startTimeUnixNano") + e.quoted(p.start) + e.key("timeUnixNano") + e.quoted(p.at) + switch inst.shape { + case shapeGauge, shapeSum: + w.writeNumber(p) + case shapeHistogram: + w.writeHistogram(p) + case shapeExponential: + w.writeExponential(p) + case shapeSummary: + w.writeSummary(p) + } + e.close('}') + if closes { + w.closeMetric(p) + } + return at +} + +// openMetric writes what a metric opens with, through the bracket of its +// points: the name, the description, the unit and the member of its shape. +func (w pointWriter) openMetric(inst *instrument) { + e := w.e + e.next() + e.open('{') + e.key("name") + e.text(inst.name) + e.key("description") + e.text(inst.description) + e.key("unit") + e.text(inst.unit) + e.key(member[inst.shape]) + e.open('{') + e.key("dataPoints") + e.open('[') +} + +// closeMetric writes what follows the last point of a metric. +func (w pointWriter) closeMetric(p *point) { + e := w.e + e.close(']') + if p.temporality != 0 { + e.key("aggregationTemporality") + e.enum(p.temporality, temporalityNames[p.temporality]) + } + if p.slot.inst.monotonic { + e.key("isMonotonic") + e.truth() + } + e.close('}') + e.close('}') +} + +// writeSeries writes the attributes of a series, with the padding attribute +// last when padded says so, and returns where the padding begins. A series +// with none and no padding writes no member at all. +func (w pointWriter) writeSeries(attrs series, padded bool) (at int) { + if len(attrs) == 0 && !padded { + return 0 + } + e := w.e + e.key("attributes") + e.open('[') + for _, a := range attrs { + if a.whole() { + e.wholeAttribute(a.key, a.number) + } else { + e.textAttribute(a.key, a.text) + } + } + if padded { + at = e.padding() + } + e.close(']') + return at +} + +// writeNumber is the value of a gauge or a sum. +func (w pointWriter) writeNumber(p *point) { + e := w.e + switch { + case p.slot.inst.whole: + e.key("asInt") + e.quoted(p.number) + case p.number < 0: + e.key("asDouble") + e.text(nonFiniteNames[-p.number]) + default: + e.key("asDouble") + e.decimal(p.number) + } +} + +func (w pointWriter) writeHistogram(p *point) { + e := w.e + w.writeDistribution(p) + e.key("explicitBounds") + e.open('[') + for _, b := range requestBounds { + e.next() + e.decimal(b) + } + e.close(']') + w.writeExemplars(p) + w.writeRange(p) +} + +func (w pointWriter) writeExponential(p *point) { + e := w.e + e.key("count") + e.quoted(p.count) + e.key("sum") + e.decimal(p.sum) + e.key("scale") + e.whole(exponentialScale) + e.key("positive") + e.open('{') + if p.offset != 0 { + e.key("offset") + e.whole(int64(p.offset)) + } + w.writeCounts(p) + e.close('}') + // Always there, also with nothing in it: that is how the Collector writes + // a histogram that measured nothing below nought. + e.key("negative") + e.nothing() + w.writeRange(p) +} + +func (w pointWriter) writeSummary(p *point) { + e := w.e + e.key("count") + e.quoted(p.count) + e.key("sum") + e.decimal(p.sum) + e.key("quantileValues") + e.open('[') + for i, q := range timerQuantiles { + e.next() + e.open('{') + e.key("quantile") + e.decimal(q) + e.key("value") + e.decimal(p.quantiles[i]) + e.close('}') + } + e.close(']') +} + +// writeDistribution is the count, the sum and the counts of the buckets of an +// explicit histogram. +func (w pointWriter) writeDistribution(p *point) { + e := w.e + e.key("count") + e.quoted(p.count) + e.key("sum") + e.decimal(p.sum) + w.writeCounts(p) +} + +func (w pointWriter) writeCounts(p *point) { + e := w.e + e.key("bucketCounts") + e.open('[') + for _, c := range p.buckets { + e.next() + e.quoted(c) + } + e.close(']') +} + +// writeExemplars writes the measurements a point names, when it names any. +// The trace id stands before the span id: measured on 2026-11-11, that is +// where the Collector writes it, against the order metrics.proto declares. +func (w pointWriter) writeExemplars(p *point) { + if p.examples == 0 { + return + } + e := w.e + e.key("exemplars") + e.open('[') + for _, ex := range p.exemplars[:p.examples] { + e.next() + e.open('{') + e.key("timeUnixNano") + e.quoted(ex.at) + e.key("asDouble") + e.decimal(ex.value) + e.key("traceId") + e.id(ex.trace[:]) + e.key("spanId") + e.id(ex.span[:]) + e.close('}') + } + e.close(']') +} + +func (w pointWriter) writeRange(p *point) { + e := w.e + e.key("min") + e.decimal(p.least) + e.key("max") + e.decimal(p.most) +} diff --git a/internal/format/otlp/otlp.go b/internal/format/otlp/otlp.go index c5d6240b..e12dd8df 100644 --- a/internal/format/otlp/otlp.go +++ b/internal/format/otlp/otlp.go @@ -85,9 +85,9 @@ func defaultSettings() settings { return s } -// minimumBytes is one batch holding the longest closing record the settings -// can write, measured rather than written down. -func minimumBytes(s settings) int64 { return newRecords(s).Shortest() } +// minimumBytes is what the file opens with and one batch holding the longest +// closing record the settings can write, measured rather than written down. +func minimumBytes(s settings) int64 { return int64(len(s.prologue())) + newRecords(s).Shortest() } func (generator) Plan(r format.Request) (format.Plan, error) { s, err := parse(r.Properties) @@ -120,8 +120,14 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { if !ok { return core.Defect(fmt.Errorf("otlp: the plan was not produced by this generator")) } - // No prologue of its own: what opens a batch is written with the first - // record of that batch, because in the default layout every line opens - // one. - return core.FillRecords(ctx, w, core.NewRand(m.seed), p.Bytes, newRecords(m.s)) + // What opens a batch is written with the first record of that batch, + // because in the default layout every line opens one. Only a pitfall + // writes anything before it. + prologue := m.s.prologue() + if len(prologue) > 0 { + if err := core.WriteAll(w, prologue); err != nil { + return err + } + } + return core.FillRecords(ctx, w, core.NewRand(m.seed), p.Bytes-int64(len(prologue)), newRecords(m.s)) } diff --git a/internal/format/otlp/pitfalls.go b/internal/format/otlp/pitfalls.go new file mode 100644 index 00000000..92057dd3 --- /dev/null +++ b/internal/format/otlp/pitfalls.go @@ -0,0 +1,88 @@ +package otlp + +import ( + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// pitfallProperties are the spellings and the shapes the readers of OTLP +// disagree about, each off unless chosen. What every one of them does to the +// OpenTelemetry Collector 0.165.0 was measured before it was written down +// here (docs/OTLP-2026-11-10.md sections 3 and 13). +func pitfallProperties() []format.Property { + return []format.Property{ + { + Name: IDEncoding, Kind: format.PropertyChoice, + Choices: []string{Base64, Hex, UpperHex}, + Default: Hex, + Group: pitfallsGroup, + Detail: pitfallDetail[IDEncoding], + }, + { + Name: EnumEncoding, Kind: format.PropertyChoice, + Choices: []string{Name, Number}, + Default: Number, + Group: pitfallsGroup, + Detail: pitfallDetail[EnumEncoding], + }, + { + Name: KeyCase, Kind: format.PropertyChoice, + Choices: []string{Camel, Snake}, + Default: Camel, + Group: pitfallsGroup, + Detail: pitfallDetail[KeyCase], + }, + { + Name: LargeNumbers, Kind: format.PropertyChoice, + Choices: []string{Number, AsText}, + Default: AsText, + Group: pitfallsGroup, + Detail: pitfallDetail[LargeNumbers], + }, + { + Name: TimeOrder, Kind: format.PropertyChoice, + Choices: []string{InOrder, OutOfOrder}, + Default: InOrder, + Group: pitfallsGroup, + Detail: pitfallDetail[TimeOrder], + }, + {Name: EndBeforeStart, Kind: format.PropertyBool, Default: "false", Group: pitfallsGroup, Detail: pitfallDetail[EndBeforeStart]}, + {Name: EmptyEnvelope, Kind: format.PropertyBool, Default: "false", Group: pitfallsGroup, Detail: pitfallDetail[EmptyEnvelope]}, + { + Name: Body, Kind: format.PropertyChoice, + Choices: []string{AnyValue, AsText}, + Default: AsText, + Group: pitfallsGroup, + Detail: pitfallDetail[Body], + }, + {Name: NonFinite, Kind: format.PropertyBool, Default: "false", Group: pitfallsGroup, Detail: pitfallDetail[NonFinite]}, + } +} + +// pitfallDetail says what each pitfall writes and what the readers do with it. +// The OpenTelemetry Collector 0.165.0 was asked about every one of them in +// every kind of telemetry on 2026-11-11, through its file receiver, and the +// guard TestEveryOTLPPitfallMeetsItsReferenceTool asks again. Its HTTP +// receiver, the protobuf library and the Rust crate were asked about spans on +// 2026-11-10 (docs/OTLP-2026-11-10.md sections 3 and 11.2). +var pitfallDetail = map[string]string{ + IDEncoding: "How trace and span ids are written. hex is what the OTLP specification asks for. " + + "upper-hex writes the same digits in capitals, which the specification allows, and the OpenTelemetry Collector writes them back in lower case. " + + "base64 writes them the way the JSON mapping of protobuf writes bytes, which the specification forbids - the file receiver of the Collector drops every batch without a word and its HTTP receiver answers 400. " + + "A reader built on the protobuf library reads base64 ids right and hex ids as something else.", + EnumEncoding: "How the kind of a span, the code of its status, the severity of a log record and the temporality of a sum or a histogram are written. number is what the OTLP specification asks for. " + + "name writes names such as SPAN_KIND_SERVER, which the specification forbids. The OpenTelemetry Collector reads them anyway and writes numbers back, and the Rust crate opentelemetry-proto refuses a span written that way.", + KeyCase: "How the keys of the file are written. camel is what the OTLP specification asks for, such as traceId. " + + "snake writes the names the .proto files give the fields, such as trace_id, which the specification calls not valid. The OpenTelemetry Collector reads them and writes camel case back, and the Rust crate opentelemetry-proto refuses the file.", + LargeNumbers: "How times and the other numbers of 64 bits are written. text is what the OTLP specification asks a writer for, such as \"1785585600000000000\". " + + "number writes them as JSON numbers, which a reader is told to take as well, and the OpenTelemetry Collector does. " + + "A reader that holds every JSON number as a double, as JavaScript does, gets every time to the nearest 256 nanoseconds.", + TimeOrder: "Whether every trace, log record or collection is dated later than the one before it. out-of-order swaps the times of every two neighbours, so every second one is dated before the one in front of it. " + + "The specification of the OTLP file promises no order, and the OpenTelemetry Collector reads the file and writes it back unchanged. What the system storing the data does with what comes late is the question this file asks.", + EndBeforeStart: "Whether every span is dated with its end before its start, the two times changed places. The OpenTelemetry Collector reads such a span and writes it back unchanged. It shapes spans, and beside signal logs or metrics it is refused.", + EmptyEnvelope: "Whether the file opens with a line holding a batch with nothing in it, which the OTLP specification asks senders not to write. " + + "The file receiver of the OpenTelemetry Collector reads the line and writes nothing for it. Only record-per-line has a line to spare, and beside another layout it is refused.", + Body: "What the body of a log record holds. text is a sentence. any-value gives the seven kinds of value OTLP has in turn - text, a boolean, the whole number 2^53 + 1, a fraction, a list, a map and bytes - " + + "and the OpenTelemetry Collector reads them and writes them back unchanged. With large_numbers number, a reader that holds numbers as doubles reads that whole number one less. It shapes log records, and beside signal traces or metrics it is refused.", + NonFinite: "Whether jvm.cpu.recent_utilization reports values that are not numbers: NaN in the second of every four collections, and Infinity and -Infinity in turn in the fourth, written as text the way the JSON mapping of protobuf writes them. " + + "The OpenTelemetry Collector reads them and writes them back unchanged. Beside a metric_kind without that gauge it is refused.", +} diff --git a/internal/format/otlp/properties.go b/internal/format/otlp/properties.go new file mode 100644 index 00000000..ac181145 --- /dev/null +++ b/internal/format/otlp/properties.go @@ -0,0 +1,206 @@ +package otlp + +import ( + "strconv" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/textenc" +) + +// recorded is what the manifest says about the file: only what is true of +// this signal and this layout, so a test never asserts on a setting that left +// no trace in the bytes. +func (s settings) recorded() map[string]any { + out := map[string]any{ + "encoding": "utf-8", + Signal: s.signal, + Formatting: s.layout.name, + Start: s.startText, + // Stated even though it is always false here, so a test can assert on + // it without knowing which formats carry a label internally. + format.PropertyLabelEmbedded: false, + } + s.recordPitfalls(out) + switch s.signal { + case Traces: + out[Depth] = s.depth + out[StatusMix] = s.statusMix + case Logs: + out[LevelMix] = s.levelMix + out[TraceContext] = s.traceContext + case Metrics: + s.recordMetrics(out) + return out + } + out[Rate] = s.rate + if s.layout.lines { + out[Batch] = s.batch + } + return out +} + +// recordMetrics states what shapes a file of metrics: the kind and the +// interval, and the temporality and the exemplars where the kind has them. +func (s settings) recordMetrics(out map[string]any) { + out[MetricKind] = s.metricKind + out[Interval] = s.interval + for _, inst := range s.instruments { + if inst.shape.temporal() { + out[Temporality] = s.temporality + } + if inst.exemplary { + out[Exemplars] = s.exemplars + } + } +} + +// recordPitfalls states every pitfall that can reach this file, chosen or not, +// so a test can assert that a file is spelled the way the specification asks. +// A pitfall that has nothing to change here is left out, by the same rule +// that refuses it when it is chosen. +func (s settings) recordPitfalls(out map[string]any) { + values := map[string]any{ + IDEncoding: s.idEncoding, EnumEncoding: pick2(s.enumNames, Name, Number), KeyCase: pick2(s.snakeKeys, Snake, Camel), + LargeNumbers: pick2(s.bareNumbers, Number, AsText), BOM: s.bom, TimeOrder: pick2(s.outOfOrder, OutOfOrder, InOrder), + EndBeforeStart: s.endBeforeStart, EmptyEnvelope: s.emptyEnvelope, Body: pick2(s.anyBody, AnyValue, AsText), NonFinite: s.nonFinite, + } + for _, p := range pitfallProperties() { + if why, _ := outOfPlace(p.Name, s); why.IsZero() { + out[p.Name] = values[p.Name] + } + } +} + +func pick2(on bool, yes, no string) string { + if on { + return yes + } + return no +} + +func properties() []format.Property { + return append(append(fileProperties(), recordProperties()...), pitfallProperties()...) +} + +// fileProperties are the settings every file has, whatever it holds. +func fileProperties() []format.Property { + return []format.Property{ + { + Name: Signal, Kind: format.PropertyChoice, + Choices: []string{Logs, Metrics, Traces}, + Default: Traces, + Detail: "Which kind of telemetry the file holds. traces writes spans, logs writes log records and metrics writes the measurements of a service, collected every interval. A file holds one kind, as the OTLP file format asks.", + }, + { + Name: Formatting, Kind: format.PropertyChoice, + Choices: []string{Indented, Minified, RecordPerLine}, + Default: RecordPerLine, + // A file of one request is one JSON document, and .jsonl would + // tell a reader that picks its parser by the name to read it line + // by line. docs/ROZSZERZENIE-UKLADU-2026-11-11.md. + Extensions: map[string]string{Indented: ".json", Minified: ".json"}, + Detail: "How the file is laid out. record-per-line puts one batch on each line, which is what the OTLP file exporter writes and the file receiver of the OpenTelemetry Collector reads, and the file name ends in .jsonl. " + + "minified and indented make the whole file one request, the body to post to an OTLP endpoint, and the file name ends in .json. " + + "Posted as a body, a file of several lines loses every line after the first in the Collector, and its file receiver reads nothing from an indented file.", + }, + // The byte order mark every text format of this tool declares, in the + // words they all share. What the readers of OTLP do with it - the + // Collector drops the first batch without a word - was measured with + // the pitfalls (docs/OTLP-2026-11-10.md section 14). + textenc.Axes(textenc.SettingBOM)[0], + { + Name: Batch, Kind: format.PropertyInt, + Min: 1, Max: maxBatch, + Default: strconv.Itoa(defaultBatch), + Detail: "How many spans or log records each line holds. 512 is the batch an OpenTelemetry SDK sends by default. Only record-per-line has batches, and beside another layout or beside signal metrics, where a line holds one collection, a batch is refused.", + }, + { + Name: Start, Kind: format.PropertyText, + Shape: "a moment in UTC such as 2026-08-01T12:00:00Z", + Default: defaultStart, + Detail: "The moment the first span or log record is dated, and the moment a service writing metrics started. The later ones follow it at the rate or every interval. Change it when the system that receives the file refuses or hides data that is too old.", + }, + { + Name: Rate, Kind: format.PropertyInt, + Min: 1, Max: maxRate, Unit: "entries per second", + Default: strconv.Itoa(defaultRate), + Detail: "How many traces or log records begin each second. Metrics are collected every interval instead, and beside signal metrics a rate is refused.", + }, + } +} + +// recordProperties are the settings that shape one kind of record. Each says +// which kind, because beside the others it is refused. +func recordProperties() []format.Property { + return append(spanAndLogProperties(), metricProperties()...) +} + +// spanAndLogProperties shape spans and log records, in a block for each. +func spanAndLogProperties() []format.Property { + return []format.Property{ + { + Name: Depth, Kind: format.PropertyInt, + Min: 1, Max: maxDepth, + Default: strconv.Itoa(defaultDepth), + Group: spansGroup, + Detail: "How many spans each trace has, each one the child of the one before it. 1 leaves every span without a parent. It shapes spans, and beside signal logs or metrics it is refused.", + }, + { + Name: StatusMix, Kind: format.PropertyChoice, + Choices: []string{"client-errors", "realistic", "server-errors", "success"}, + Default: "realistic", + Group: spansGroup, + Detail: "Which response codes the requests end with. A code of 500 or above marks its span as an error, as the HTTP conventions of OpenTelemetry ask, and every other code leaves the status unset. It shapes spans, and beside signal logs or metrics it is refused.", + }, + { + Name: LevelMix, Kind: format.PropertyChoice, + Choices: []string{"debug", "errors", "quiet", "realistic"}, + Default: "realistic", + Group: logsGroup, + Detail: "Which severities the log records carry. Realistic is mostly INFO with some of the others. It shapes log records, and beside signal traces or metrics it is refused.", + }, + { + Name: TraceContext, Kind: format.PropertyBool, + Default: "true", + Group: logsGroup, + Detail: "Whether every log record names the trace and the span it was written in. The file holds no spans, so those ids match nothing in it. It shapes log records, and beside signal traces or metrics it is refused.", + }, + } +} + +// metricProperties shape a file of metrics. +func metricProperties() []format.Property { + return []format.Property{ + { + Name: MetricKind, Kind: format.PropertyChoice, + Choices: []string{ExponentialHistogram, Gauge, Histogram, Mixed, Sum, Summary}, + Default: Mixed, + Group: metricsGroup, + Detail: "Which metrics the file holds. gauge writes jvm.cpu.recent_utilization and shop.orders.pending, sum writes jvm.cpu.time, jvm.class.loaded and jvm.memory.used, " + + "histogram writes http.server.request.duration in the buckets the OpenTelemetry conventions name, exponential-histogram writes jvm.gc.duration in buckets that double every eight, " + + "and summary writes http.server.requests as three percentiles, the way Micrometer writes a timer. mixed writes all of them. gauge and sum each hold whole numbers and fractions.", + }, + { + Name: Temporality, Kind: format.PropertyChoice, + Choices: []string{Cumulative, Delta}, + Default: Cumulative, + Group: metricsGroup, + Detail: "Whether a sum or a histogram counts everything since the service started or only what came since the collection before. cumulative is what an SDK sends by default. " + + "delta leaves jvm.memory.used cumulative, as the delta setting of an SDK does. Beside metric_kind gauge or summary it is refused.", + }, + { + Name: Exemplars, Kind: format.PropertyBool, + Default: "false", + Group: metricsGroup, + Detail: "Whether the durations of requests name the trace and the span a measurement was taken in, one for each bucket a collection measured in. " + + "The file holds no spans, so those ids match nothing in it. Only http.server.request.duration has them, and beside a metric_kind without it exemplars are refused.", + }, + { + Name: Interval, Kind: format.PropertyInt, + Min: 1, Max: maxInterval, Unit: "seconds", + Default: strconv.Itoa(defaultInterval), + Group: metricsGroup, + Detail: "How long a service waits between two collections of its metrics. 60 is what an SDK uses by default. Every point of a collection is dated at the same moment, and in record-per-line a line holds one collection.", + }, + } +} diff --git a/internal/format/otlp/records.go b/internal/format/otlp/records.go index e8234ed9..068008ab 100644 --- a/internal/format/otlp/records.go +++ b/internal/format/otlp/records.go @@ -25,6 +25,8 @@ type records struct { tail []byte st, before state shortest int64 + // m is how a file of metrics is laid out, nil for spans and log records. + m *metricPlan } // state is what the builder remembers from one record to the next. Kept in one @@ -41,8 +43,11 @@ type state struct { func newRecords(s settings) *records { r := &records{ - s: s, e: emitter{indent: s.layout.indent}, - open: opening(envelopes[s.signal], s.layout.indent), shut: closing(s.layout), + s: s, e: emitter{indent: s.layout.indent, sp: s.spelling()}, + open: opening(envelopes[s.signal], s.layout.indent, s.spelling()), shut: closing(s.layout, s.spelling()), + } + if s.signal == Metrics { + r.m = newMetricPlan(s) } r.shortest = r.measureShortest() return r @@ -54,9 +59,15 @@ func newRecords(s settings) *records { func (r *records) Shortest() int64 { return r.shortest } // Discard takes back everything the thrown away record changed: its place in -// the batch, its tick of the clock and its place in its trace. The closing -// record then carries all three instead. -func (r *records) Discard() { r.st = r.before } +// the batch, its tick of the clock and its place in its trace, or what its +// series of metrics had measured. The closing record then carries them all +// instead. +func (r *records) Discard() { + r.st = r.before + if r.m != nil { + r.m.states[r.m.savedAt] = r.m.saved + } +} func (r *records) Append(dst []byte, rng *rand.Rand) []byte { r.before = r.st @@ -88,10 +99,19 @@ func (r *records) record(dst []byte, rng *rand.Rand, padded bool) (out []byte, a dst = append(dst, r.open...) } r.e.b, r.e.depth, r.e.fresh = dst, recordDepth, r.st.inBatch == 0 - if r.s.signal == Traces { + switch r.s.signal { + case Traces: at = r.writeSpan(r.drawSpan(rng), padded) - } else { + case Logs: at = r.writeEntry(r.drawEntry(rng, r.st.count), padded) + default: + p := r.m.draw(rng, r.st.count) + if p.slot.series > 0 { + // Inside the metric the point before it opened. + r.e.depth, r.e.fresh = pointDepth, false + } + // The closing point of the file closes its metric wherever it falls. + at = pointWriter{&r.e}.write(p, p.slot.series == 0, padded, padded || p.slot.last) } r.st.count++ r.st.inBatch++ @@ -109,7 +129,22 @@ func (r *records) measureShortest() int64 { for _, en := range r.widestEntries() { widest = max(widest, r.measured(func() { r.writeEntry(en, true) })) } - return int64(len(r.open) + widest + len(r.shut)) + return int64(len(r.open) + max(widest, r.widestPoint()) + len(r.shut)) +} + +// widestPoint is the longest closing point the settings can write, nought +// when the file holds no metrics. Measured as the first of its metric, with +// the name in front and the close behind, because the closing point may be +// either or both. +func (r *records) widestPoint() int { + if r.m == nil { + return 0 + } + widest := 0 + for _, p := range r.m.widest() { + widest = max(widest, r.measured(func() { pointWriter{&r.e}.write(&p, true, true, true) })) + } + return widest } // measured is how many bytes write adds as the first record of a batch. diff --git a/internal/format/otlp/refusals.go b/internal/format/otlp/refusals.go new file mode 100644 index 00000000..542e1ca0 --- /dev/null +++ b/internal/format/otlp/refusals.go @@ -0,0 +1,146 @@ +package otlp + +import ( + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// misplaced refuses a setting somebody chose that can do nothing in this +// file: one that shapes spans beside logs, one that shapes log records beside +// traces, and a batch beside a layout that has none. +// +// Only a real choice can be out of place. A window sends every setting it +// draws, at its default, so a key that arrived says nothing - the same lesson +// the log format wrote down on 2026-08-31. +func misplaced(props map[string]string, s settings) error { + for _, p := range properties() { + v, chosen := props[p.Name] + if !chosen || v == "" || v == p.Default { + continue + } + if why, other := outOfPlace(p.Name, s); !why.IsZero() { + return conflict(p.Name, other, v, why) + } + } + return nil +} + +// outOfPlace says why a setting does nothing beside the rest of s, and which +// other setting it disagrees with. Nothing when it has work to do. +func outOfPlace(name string, s settings) (why core.Said, other string) { + chosen := core.Choice{Of: Signal, Value: s.signal} + switch name { + case Depth, StatusMix: + if s.signal != Traces { + return core.Says("otlp.ItShapesSpansAndSignal", "it shapes spans, and signal %s writes none", core.A("Signal", chosen)), Signal + } + case LevelMix, TraceContext: + if s.signal != Logs { + return core.Says("otlp.ItShapesLogRecordsAndSignal", "it shapes log records, and signal %s writes none", core.A("Signal", chosen)), Signal + } + case Batch: + if !s.layout.lines { + return core.Says("otlp.OnlyOneLayoutHasBatches", "only %s cuts the file into batches, and %s writes one request", + core.A("Lines", core.Choice{Of: Formatting, Value: RecordPerLine}), + core.A("Layout", core.Choice{Of: Formatting, Value: s.layout.name})), Formatting + } + if s.signal == Metrics { + return core.Says("otlp.ALineOfMetricsIsOneCollection", "a line of %s holds one collection, so there is no batch to choose", core.A("Signal", chosen)), Signal + } + case Rate: + if s.signal == Metrics { + return core.Says("otlp.MetricsComeEveryInterval", "it sets how often a trace or a log record begins, and %s collects once every interval", core.A("Signal", chosen)), Signal + } + case MetricKind, Temporality, Exemplars, Interval: + if s.signal != Metrics { + return core.Says("otlp.ItShapesMetricsAndSignal", "it shapes metrics, and signal %s writes none", core.A("Signal", chosen)), Signal + } + return s.outOfKind(name) + default: + return s.outOfPitfall(name) + } + return core.Said{}, "" +} + +// outOfPitfall says why a pitfall does nothing beside the rest of s: one +// that shapes a kind of record the file does not hold, an empty batch where +// there are no lines, ids where nothing names one, an enum where there is none. +func (s settings) outOfPitfall(name string) (why core.Said, other string) { + chosen := core.Choice{Of: Signal, Value: s.signal} + switch name { + case EndBeforeStart: + if s.signal != Traces { + return core.Says("otlp.ItShapesSpansAndSignal", "it shapes spans, and signal %s writes none", core.A("Signal", chosen)), Signal + } + case Body: + if s.signal != Logs { + return core.Says("otlp.ItShapesLogRecordsAndSignal", "it shapes log records, and signal %s writes none", core.A("Signal", chosen)), Signal + } + case NonFinite: + if s.signal != Metrics { + return core.Says("otlp.ItShapesMetricsAndSignal", "it shapes metrics, and signal %s writes none", core.A("Signal", chosen)), Signal + } + return s.outOfKind(name) + case EmptyEnvelope: + if !s.layout.lines { + return core.Says("otlp.OnlyOneLayoutHasLinesToAdd", "only %s has a line to spare for an empty batch, and %s writes one request", + core.A("Lines", core.Choice{Of: Formatting, Value: RecordPerLine}), + core.A("Layout", core.Choice{Of: Formatting, Value: s.layout.name})), Formatting + } + case IDEncoding: + return s.withoutIDs() + case EnumEncoding: + if s.signal == Metrics { + return s.outOfKind(name) + } + } + return core.Said{}, "" +} + +// withoutIDs says why an id_encoding does nothing: log records that name no +// trace, or metrics that name no measurement. +func (s settings) withoutIDs() (why core.Said, other string) { + switch { + case s.signal == Logs && !s.traceContext: + return core.Says("otlp.TheseLogRecordsNameNoTrace", "it spells the ids of traces and spans, and %s writes none", + core.A("Off", core.Choice{Of: TraceContext, Value: "false"})), TraceContext + case s.signal == Metrics && !s.exemplars: + return core.Says("otlp.TheseMetricsNameNoTrace", "it spells the ids of traces and spans, and metrics name them only with %s", + core.A("On", core.Choice{Of: Exemplars, Value: "true"})), Exemplars + } + return core.Said{}, "" +} + +// outOfKind says why a setting of metrics does nothing beside the kind of +// metric chosen: a temporality where no sum and no histogram is written, and +// exemplars where no instrument measures inside a request. +func (s settings) outOfKind(name string) (why core.Said, other string) { + kind := core.Choice{Of: MetricKind, Value: s.metricKind} + temporal, exemplary, fractions := false, false, false + for _, inst := range s.instruments { + temporal = temporal || inst.shape.temporal() + exemplary = exemplary || inst.exemplary + fractions = fractions || inst.values == cpuUtilization + } + switch { + case name == Temporality && !temporal: + return core.Says("otlp.OnlySumsAndHistogramsAddUp", "only a sum or a histogram adds up over time, and %s writes neither", core.A("Kind", kind)), MetricKind + case name == EnumEncoding && !temporal: + return core.Says("otlp.TheseMetricsHaveNoEnum", "the one enum of metrics is the temporality of a sum or a histogram, and %s writes neither", core.A("Kind", kind)), MetricKind + case name == Exemplars && !exemplary: + return core.Says("otlp.OnlyRequestDurationsHaveExemplars", "only the durations of requests, a histogram, name the traces they were measured in, and %s writes none", core.A("Kind", kind)), MetricKind + case name == NonFinite && !fractions: + return core.Says("otlp.OnlyTheUtilizationGoesNaN", "only jvm.cpu.recent_utilization, a gauge, reports a value that is not a number, and %s writes no gauge", core.A("Kind", kind)), MetricKind + } + return core.Said{}, "" +} + +// conflict names both settings, because naming one of a pair leaves the reader +// to guess which of the two to change. +func conflict(one, other, val string, why core.Said) error { + return &format.PropertyValueError{ + Format: "otlp", Key: one + " and " + other, Value: val, + Subject: core.Says("format.TwoSettings", "%s and %s", core.A("Of", core.LabelTerm(one)), core.A("By", core.LabelTerm(other))), + Reason: core.Says("otlp.DropOneOfTheTwo", "%s. Drop one of the two, or change the other", core.A("Why", why)), + } +} diff --git a/internal/format/otlp/settings.go b/internal/format/otlp/settings.go index f53b9888..e449587d 100644 --- a/internal/format/otlp/settings.go +++ b/internal/format/otlp/settings.go @@ -7,6 +7,7 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/textenc" ) // The names of the settings, for the guards that set them. @@ -20,13 +21,70 @@ const ( StatusMix = "status_mix" LevelMix = "level_mix" TraceContext = "trace_context" + MetricKind = "metric_kind" + Temporality = "temporality" + Exemplars = "exemplars" + Interval = "interval" + + IDEncoding = "id_encoding" + EnumEncoding = "enum_encoding" + KeyCase = "key_case" + LargeNumbers = "large_numbers" + BOM = textenc.SettingBOM + TimeOrder = "time_order" + EndBeforeStart = "end_before_start" + EmptyEnvelope = "empty_envelope" + Body = "body" + NonFinite = "non_finite" +) + +// The values of the pitfalls, the first of each pair the spelling of the +// specification. +const ( + Hex = "hex" + UpperHex = "upper-hex" + Base64 = "base64" + Number = "number" + Name = "name" + Camel = "camel" + Snake = "snake" + AsText = "text" + InOrder = "in-order" + OutOfOrder = "out-of-order" + AnyValue = "any-value" ) // The kinds of telemetry a file can hold. One kind to a file, which is what // the specification of the OTLP file exporter asks. const ( - Traces = "traces" - Logs = "logs" + Traces = "traces" + Logs = "logs" + Metrics = "metrics" +) + +// The kinds of metric, under the names metric_kind gives them: one for each +// kind of data metrics.proto has, and all of them together. +const ( + Gauge = "gauge" + Sum = "sum" + Histogram = "histogram" + ExponentialHistogram = "exponential-histogram" + Summary = "summary" + Mixed = "mixed" +) + +// The aggregation temporalities, under the names temporality gives them. +const ( + Cumulative = "cumulative" + Delta = "delta" +) + +// The blocks the settings that shape one kind of record are drawn in. +const ( + spansGroup = "Spans" + logsGroup = "Log records" + metricsGroup = "Metrics" + pitfallsGroup = "Pitfalls" ) // The layouts, under the names json and geojson use for theirs. @@ -50,6 +108,12 @@ const ( defaultDepth = 3 maxDepth = 100 + // defaultInterval is how often the periodic reader of an SDK collects, + // exportIntervalMillis in metrics/sdk.md of the specification. A day at + // the most. + defaultInterval = 60 + maxInterval = 86400 + // defaultStart is the moment the log format starts at, so two files of // one run tell the same time. defaultStart = "2026-08-01T12:00:00Z" @@ -88,10 +152,80 @@ type settings struct { levelMix string levels []level traceContext bool + + metricChoices + pitfallChoices +} + +// metricChoices are what shapes a file of metrics. +type metricChoices struct { + metricKind string + instruments []*instrument + temporality string + exemplars bool + // interval is how many seconds lie between two collections. + interval int64 +} + +// pitfallChoices are the pitfalls. Each is off, or spelled the way the +// specification asks, unless chosen. +type pitfallChoices struct { + idEncoding string + enumNames bool + snakeKeys bool + bareNumbers bool + bom bool + outOfOrder bool + endBeforeStart bool + emptyEnvelope bool + anyBody bool + nonFinite bool +} + +// order is the place on the clock of the trace, log record or collection a +// file draws as the nth: the nth itself, or with time_order out-of-order its +// neighbour, so every second one is dated before the one in front of it. +func (s settings) order(n int64) int64 { + if s.outOfOrder { + return n ^ 1 + } + return n +} + +// prologue is what the file opens with before its first batch: a byte order +// mark, and a line holding an empty envelope. +func (s settings) prologue() []byte { + var out []byte + if s.bom { + out = append(out, 0xEF, 0xBB, 0xBF) + } + if s.emptyEnvelope { + e := &emitter{sp: s.spelling()} + e.open('{') + e.key(envelopes[s.signal].resources) + e.b = append(e.b, '[', ']', '}', '\n') + out = append(out, e.b...) + } + return out } -// step is how far the clock moves from one trace or log record to the next. -func (s settings) step() int64 { return int64(time.Second) / int64(s.rate) } +// step is how far the clock moves from one trace, log record or collection to +// the next. +func (s settings) step() int64 { + if s.signal == Metrics { + return s.interval * int64(time.Second) + } + return int64(time.Second) / int64(s.rate) +} + +// points is how many points a collection of these settings holds. +func (s settings) points() int { + n := 0 + for _, inst := range s.instruments { + n += len(inst.series) + } + return n +} func parse(props map[string]string) (settings, error) { values := map[string]string{} @@ -105,7 +239,28 @@ func parse(props map[string]string) (settings, error) { batch, _ := strconv.Atoi(values[Batch]) rate, _ := strconv.Atoi(values[Rate]) depth, _ := strconv.Atoi(values[Depth]) + interval, _ := strconv.ParseInt(values[Interval], 10, 64) s := settings{ + metricChoices: metricChoices{ + metricKind: values[MetricKind], + instruments: kinds[values[MetricKind]], + temporality: values[Temporality], + exemplars: values[Exemplars] == "true", + interval: interval, + }, + pitfallChoices: pitfallChoices{ + idEncoding: values[IDEncoding], + enumNames: values[EnumEncoding] == Name, + snakeKeys: values[KeyCase] == Snake, + bareNumbers: values[LargeNumbers] == Number, + bom: values[BOM] == "true", + outOfOrder: values[TimeOrder] == OutOfOrder, + endBeforeStart: values[EndBeforeStart] == "true", + emptyEnvelope: values[EmptyEnvelope] == "true", + anyBody: values[Body] == AnyValue, + nonFinite: values[NonFinite] == "true", + }, + signal: values[Signal], layout: layouts[values[Formatting]], batch: batch, @@ -123,6 +278,11 @@ func parse(props map[string]string) (settings, error) { return settings{}, err } s.start = start + if s.signal == Metrics { + // A line of metrics is one collection, so the batch is as many + // points as a collection holds rather than a number anybody chose. + s.batch = s.points() + } return s, misplaced(props, s) } @@ -169,6 +329,9 @@ func readStart(raw string) (int64, error) { // that carries it is a byte longer than planned, and the file would come out // the wrong size somewhere near its end. func (s settings) endsInTime(bytes int64) error { + if s.signal == Metrics { + return s.collectsInTime(bytes) + } ticks := bytes / leastRecordBytes if room := (lastNanos - s.start - int64(longestSpan)) / s.step(); ticks <= room { return nil @@ -184,158 +347,36 @@ func (s settings) endsInTime(bytes int64) error { } } -// misplaced refuses a setting somebody chose that can do nothing in this -// file: one that shapes spans beside logs, one that shapes log records beside -// traces, and a batch beside a layout that has none. +// collectsInTime is endsInTime for a file of metrics, which counts +// collections rather than records, and asks one thing more. // -// Only a real choice can be out of place. A window sends every setting it -// draws, at its default, so a key that arrived says nothing - the same lesson -// the log format wrote down on 2026-08-31. -func misplaced(props map[string]string, s settings) error { - for _, p := range properties() { - v, chosen := props[p.Name] - if !chosen || v == "" || v == p.Default { - continue - } - if why, other := outOfPlace(p.Name, s); !why.IsZero() { - return conflict(p.Name, other, v, why) +// The time a cumulative series has measured grows with every collection and +// is written to the microsecond. Past microCeiling a sum would need a seventh +// decimal to be read back as written, so a file that could reach it is +// refused - a file of hundreds of gigabytes at the least, and worth the +// refusal for the reason endsInTime gives. +func (s settings) collectsInTime(bytes int64) error { + // One more than the file can hold, and one more for time_order + // out-of-order, which dates a collection where the next would stand. + collections := bytes/(leastRecordBytes*int64(s.points())) + 2 + if room := (lastNanos - s.start) / s.step(); collections > room { + return &format.PropertyValueError{ + Format: "otlp", Key: Start, Value: s.startText, + Reason: core.Says("otlp.AFileThisLargeCollectsAfter", + "a file of this size collected every %d s would date its last collections after the year 2262. Choose an earlier start, a longer interval or a smaller file", + core.A("Interval", s.interval)), } } - return nil -} - -// outOfPlace says why a setting does nothing beside the rest of s, and which -// other setting it disagrees with. Nothing when it has work to do. -func outOfPlace(name string, s settings) (why core.Said, other string) { - chosen := core.Choice{Of: Signal, Value: s.signal} - switch name { - case Depth, StatusMix: - if s.signal != Traces { - return core.Says("otlp.ItShapesSpansAndSignal", "it shapes spans, and signal %s writes none", core.A("Signal", chosen)), Signal - } - case LevelMix, TraceContext: - if s.signal != Logs { - return core.Says("otlp.ItShapesLogRecordsAndSignal", "it shapes log records, and signal %s writes none", core.A("Signal", chosen)), Signal + for _, inst := range s.instruments { + measures := inst.shape == shapeHistogram || inst.shape == shapeExponential || inst.shape == shapeSummary + if measures && s.cumulative(inst) && collections > (microCeiling-1)/(mostMeasurements*longestDuration) { + return &format.PropertyValueError{ + Format: "otlp", Key: MetricKind, Value: s.metricKind, + Reason: core.Says("otlp.AFileThisLargeAddsUpMore", + "a file of this size would add up more time in one series of %s than can be written to the microsecond. Choose a smaller file, or temporality delta where the kind has it", + core.A("Metric", inst.name)), + } } - case Batch: - if !s.layout.lines { - return core.Says("otlp.OnlyOneLayoutHasBatches", "only %s cuts the file into batches, and %s writes one request", - core.A("Lines", core.Choice{Of: Formatting, Value: RecordPerLine}), - core.A("Layout", core.Choice{Of: Formatting, Value: s.layout.name})), Formatting - } - } - return core.Said{}, "" -} - -// conflict names both settings, because naming one of a pair leaves the reader -// to guess which of the two to change. -func conflict(one, other, val string, why core.Said) error { - return &format.PropertyValueError{ - Format: "otlp", Key: one + " and " + other, Value: val, - Subject: core.Says("format.TwoSettings", "%s and %s", core.A("Of", core.LabelTerm(one)), core.A("By", core.LabelTerm(other))), - Reason: core.Says("otlp.DropOneOfTheTwo", "%s. Drop one of the two, or change the other", core.A("Why", why)), - } -} - -// recorded is what the manifest says about the file: only what is true of -// this signal and this layout, so a test never asserts on a setting that left -// no trace in the bytes. -func (s settings) recorded() map[string]any { - out := map[string]any{ - "encoding": "utf-8", - Signal: s.signal, - Formatting: s.layout.name, - Start: s.startText, - Rate: s.rate, - // Stated even though it is always false here, so a test can assert on - // it without knowing which formats carry a label internally. - format.PropertyLabelEmbedded: false, - } - if s.layout.lines { - out[Batch] = s.batch - } - if s.signal == Traces { - out[Depth] = s.depth - out[StatusMix] = s.statusMix - } else { - out[LevelMix] = s.levelMix - out[TraceContext] = s.traceContext - } - return out -} - -func properties() []format.Property { - return append(fileProperties(), recordProperties()...) -} - -// fileProperties are the settings every file has, whatever it holds. -func fileProperties() []format.Property { - return []format.Property{ - { - Name: Signal, Kind: format.PropertyChoice, - Choices: []string{Logs, Traces}, - Default: Traces, - Detail: "Which kind of telemetry the file holds. traces writes spans and logs writes log records. A file holds one kind, as the OTLP file format asks.", - }, - { - Name: Formatting, Kind: format.PropertyChoice, - Choices: []string{Indented, Minified, RecordPerLine}, - Default: RecordPerLine, - // A file of one request is one JSON document, and .jsonl would - // tell a reader that picks its parser by the name to read it line - // by line. docs/ROZSZERZENIE-UKLADU-2026-11-11.md. - Extensions: map[string]string{Indented: ".json", Minified: ".json"}, - Detail: "How the file is laid out. record-per-line puts one batch on each line, which is what the OTLP file exporter writes and the file receiver of the OpenTelemetry Collector reads, and the file name ends in .jsonl. " + - "minified and indented make the whole file one request, the body to post to an OTLP endpoint, and the file name ends in .json. " + - "Posted as a body, a file of several lines loses every line after the first in the Collector, and its file receiver reads nothing from an indented file.", - }, - { - Name: Batch, Kind: format.PropertyInt, - Min: 1, Max: maxBatch, - Default: strconv.Itoa(defaultBatch), - Detail: "How many spans or log records each line holds. 512 is the batch an OpenTelemetry SDK sends by default. Only record-per-line has batches, and beside another layout a batch is refused.", - }, - { - Name: Start, Kind: format.PropertyText, - Shape: "a moment in UTC such as 2026-08-01T12:00:00Z", - Default: defaultStart, - Detail: "The moment the first span or log record is dated. The later ones follow it at the rate. Change it when the system that receives the file refuses or hides data that is too old.", - }, - { - Name: Rate, Kind: format.PropertyInt, - Min: 1, Max: maxRate, Unit: "entries per second", - Default: strconv.Itoa(defaultRate), - Detail: "How many traces or log records begin each second.", - }, - } -} - -// recordProperties are the settings that shape one kind of record. Each says -// which kind, because beside the other it is refused. -func recordProperties() []format.Property { - return []format.Property{ - { - Name: Depth, Kind: format.PropertyInt, - Min: 1, Max: maxDepth, - Default: strconv.Itoa(defaultDepth), - Detail: "How many spans each trace has, each one the child of the one before it. 1 leaves every span without a parent. It shapes spans, and beside signal logs it is refused.", - }, - { - Name: StatusMix, Kind: format.PropertyChoice, - Choices: []string{"client-errors", "realistic", "server-errors", "success"}, - Default: "realistic", - Detail: "Which response codes the requests end with. A code of 500 or above marks its span as an error, as the HTTP conventions of OpenTelemetry ask, and every other code leaves the status unset. It shapes spans, and beside signal logs it is refused.", - }, - { - Name: LevelMix, Kind: format.PropertyChoice, - Choices: []string{"debug", "errors", "quiet", "realistic"}, - Default: "realistic", - Detail: "Which severities the log records carry. Realistic is mostly INFO with some of the others. It shapes log records, and beside signal traces it is refused.", - }, - { - Name: TraceContext, Kind: format.PropertyBool, - Default: "true", - Detail: "Whether every log record names the trace and the span it was written in. The file holds no spans, so those ids match nothing in it. It shapes log records, and beside signal traces it is refused.", - }, } + return nil } diff --git a/internal/format/otlp/spans.go b/internal/format/otlp/spans.go index 733b9f3c..d542a4d5 100644 --- a/internal/format/otlp/spans.go +++ b/internal/format/otlp/spans.go @@ -81,7 +81,7 @@ func (r *records) drawSpan(rng *rand.Rand) span { t := &r.st.trace if t.level == 0 { drawID(t.id[:], rng) - t.start = r.s.start + t.begun*r.s.step() + t.start = r.s.start + r.s.order(t.begun)*r.s.step() t.length = int64(shortestSpan) + rng.Int64N(int64(longestSpan-shortestSpan)) t.begun++ } @@ -166,16 +166,20 @@ func (r *records) writeSpan(sp span, padded bool) (at int) { if sp.level == 0 { e.text(sp.request.method + " " + sp.request.route) e.key("kind") - e.whole(kindServer) + e.enum(kindServer, "SPAN_KIND_SERVER") } else { e.text(sp.function) e.key("kind") - e.whole(kindInternal) + e.enum(kindInternal, "SPAN_KIND_INTERNAL") + } + begin, end := sp.start, sp.end + if r.s.endBeforeStart { + begin, end = end, begin } e.key("startTimeUnixNano") - e.quoted(sp.start) + e.quoted(begin) e.key("endTimeUnixNano") - e.quoted(sp.end) + e.quoted(end) e.key("attributes") e.open('[') if sp.level == 0 { @@ -191,7 +195,7 @@ func (r *records) writeSpan(sp span, padded bool) (at int) { if sp.failed() { e.open('{') e.key("code") - e.whole(statusError) + e.enum(statusError, "STATUS_CODE_ERROR") e.close('}') } else { // Always there, also with nothing in it: that is how the Collector diff --git a/internal/format/otlp/vocabulary.go b/internal/format/otlp/vocabulary.go index 712481a2..563b9620 100644 --- a/internal/format/otlp/vocabulary.go +++ b/internal/format/otlp/vocabulary.go @@ -95,6 +95,8 @@ var functions = []string{ type level struct { text string number int64 + // enum is the name logs.proto gives the number. + enum string // messages are what a record of this severity says, so an ERROR never // reports that an order was accepted. messages []string @@ -103,10 +105,10 @@ type level struct { // The four severities, with the numbers logs.proto gives the first of each // range. var ( - levelDebug = level{"DEBUG", 5, []string{"cache miss", "loading order", "query finished", "stock checked"}} - levelInfo = level{"INFO", 9, []string{"order accepted", "invoice sent", "customer signed in", "report generated"}} - levelWarn = level{"WARN", 13, []string{"slow query", "retrying payment", "stock running low"}} - levelError = level{"ERROR", 17, []string{"payment refused", "invoice could not be rendered", "upstream timed out"}} + levelDebug = level{"DEBUG", 5, "SEVERITY_NUMBER_DEBUG", []string{"cache miss", "loading order", "query finished", "stock checked"}} + levelInfo = level{"INFO", 9, "SEVERITY_NUMBER_INFO", []string{"order accepted", "invoice sent", "customer signed in", "report generated"}} + levelWarn = level{"WARN", 13, "SEVERITY_NUMBER_WARN", []string{"slow query", "retrying payment", "stock running low"}} + levelError = level{"ERROR", 17, "SEVERITY_NUMBER_ERROR", []string{"payment refused", "invoice could not be rendered", "upstream timed out"}} ) // levelSets are the mixes of severities, under the names and in the shares the diff --git a/internal/guard/choiceseed_test.go b/internal/guard/choiceseed_test.go index b8a6d315..ccb11827 100644 --- a/internal/guard/choiceseed_test.go +++ b/internal/guard/choiceseed_test.go @@ -115,6 +115,16 @@ var choiceCompanions = map[string]map[string]string{ "otlp.level_mix=errors": {"signal": "logs"}, "otlp.level_mix=quiet": {"signal": "logs"}, "otlp.trace_context=false": {"signal": "logs"}, + "otlp.body=any-value": {"signal": "logs"}, + // And one that shapes metrics, a file of metrics. + "otlp.metric_kind=exponential-histogram": {"signal": "metrics"}, + "otlp.metric_kind=gauge": {"signal": "metrics"}, + "otlp.metric_kind=histogram": {"signal": "metrics"}, + "otlp.metric_kind=sum": {"signal": "metrics"}, + "otlp.metric_kind=summary": {"signal": "metrics"}, + "otlp.temporality=delta": {"signal": "metrics"}, + "otlp.exemplars=true": {"signal": "metrics"}, + "otlp.non_finite=true": {"signal": "metrics"}, // XML in UTF-16 opens with a byte order mark. "xml.encoding=utf-16be": {"bom": "true"}, "xml.encoding=utf-16le": {"bom": "true"}, diff --git a/internal/guard/damagewitness_test.go b/internal/guard/damagewitness_test.go index d431077d..6ef51795 100644 --- a/internal/guard/damagewitness_test.go +++ b/internal/guard/damagewitness_test.go @@ -94,7 +94,11 @@ var policyRefusals = map[[2]string][]string{ // next, measured 2026-11-11 (docs/OTLP-2026-11-10.md section 12.3). The // checker names the line the damage is on. {damage.ZeroHead, "log"}: {"line 1 is not a whole entry in any shape this tool writes", "the file does not end with a newline"}, - {damage.ZeroHead, "otlp"}: {"line 1 is not JSON", "the file does not end with a newline"}, + // The head the damage zeroes also held the byte order mark or the empty + // envelope the pitfalls put first, and the checker names that before it + // reaches line 1. + {damage.ZeroHead, "otlp"}: {"line 1 is not JSON", "the file does not end with a newline", + "does not start with a byte order mark", "does not open with the line"}, {damage.Truncate, "otlp"}: {"the file does not end with a newline"}, {damage.AppendJunk, "otlp"}: {"not valid UTF-8"}, {damage.AppendJunk, "ico"}: {}, diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go index a5f6190c..ea92528e 100644 --- a/internal/guard/generatorbytes_test.go +++ b/internal/guard/generatorbytes_test.go @@ -443,8 +443,13 @@ func rtfGoldenCases() map[string]engine.Target { // telemetryGoldenCases are the golden cases of the otlp format, a table of // their own for the reason mailGoldenCases gives. func telemetryGoldenCases() map[string]engine.Target { - one := func(props map[string]string) engine.Target { - return engine.Target{ID: "g", Format: "otlp", Sizes: engine.Uniform(1, 8192), Label: true, Properties: props} + sized := func(size int64, props map[string]string) engine.Target { + return engine.Target{ID: "g", Format: "otlp", Sizes: engine.Uniform(1, size), Label: true, Properties: props} + } + one := func(props map[string]string) engine.Target { return sized(8192, props) } + metrics := func(props map[string]string) map[string]string { + props["signal"] = "metrics" + return props } return map[string]engine.Target{ // One case for each path the bytes take rather than one for each @@ -468,6 +473,30 @@ func telemetryGoldenCases() map[string]engine.Target { "otlp_8kib_logs": one(map[string]string{"signal": "logs"}), "otlp_8kib_logs_batches": one(map[string]string{"signal": "logs", "batch": "2", "rate": "1000000"}), "otlp_8kib_logs_indented_no_trace": one(map[string]string{"signal": "logs", "formatting": "indented", "trace_context": "false", "level_mix": "errors"}), + + // Metrics: every kind in one collection cut short, so every name and + // every closing is written once. Gauges and sums over many lines on + // two clocks and both temporalities, where a line ends a collection. + // Request durations with exemplars counted by the collection, the one + // case of an exemplar and of a bucket kept by its last measurement. + // Exponential buckets over lines, where the first bucket written + // moves. The summary indented, where its quantiles nest deepest. + "otlp_8kib_metrics": one(metrics(map[string]string{})), + "otlp_8kib_metrics_gauge": one(metrics(map[string]string{"metric_kind": "gauge", "interval": "1"})), + "otlp_8kib_metrics_sum_delta": one(metrics(map[string]string{"metric_kind": "sum", "temporality": "delta", "interval": "86400"})), + "otlp_40kib_metrics_histogram_exemplars": sized(40960, metrics(map[string]string{"metric_kind": "histogram", "temporality": "delta", "exemplars": "true"})), + "otlp_8kib_metrics_exponential": one(metrics(map[string]string{"metric_kind": "exponential-histogram", "start": "2002-01-01T00:00:00Z"})), + "otlp_20kib_metrics_summary_indented": sized(20480, metrics(map[string]string{"metric_kind": "summary", "formatting": "indented"})), + + // The pitfalls, every one in at least one case and as many as fit + // together in each: what each spells and dates differently is a path + // of its own through the bytes. + "otlp_8kib_pitfalls_spans": one(map[string]string{"batch": "3", "id_encoding": "base64", "enum_encoding": "name", "key_case": "snake", + "large_numbers": "number", "bom": "true", "time_order": "out-of-order", "end_before_start": "true", "empty_envelope": "true"}), + "otlp_8kib_pitfalls_logs": one(map[string]string{"signal": "logs", "batch": "2", "id_encoding": "upper-hex", "body": "any-value", + "large_numbers": "number", "time_order": "out-of-order"}), + "otlp_40kib_pitfalls_metrics": sized(40960, metrics(map[string]string{"exemplars": "true", "id_encoding": "upper-hex", "enum_encoding": "name", + "non_finite": "true", "key_case": "snake"})), } } diff --git a/internal/guard/mutationcoverage_test.go b/internal/guard/mutationcoverage_test.go index 7013c4c1..b2970fbf 100644 --- a/internal/guard/mutationcoverage_test.go +++ b/internal/guard/mutationcoverage_test.go @@ -141,6 +141,15 @@ var provenByProbe = map[string]string{ // against is the break. "TestAnAVIFLargerThanOneAV1TileSurvivesItsReferenceTool": "broken on 2026-11-11 by gav1d v0.2.5, go.mod as it stood before v0.2.6: both pictures went red saying tile_start_and_end_present_flag is set in an OBU_FRAME, " + "the tile_info of several tiles read over the data of one. With v0.2.6 both read back as 2 tiles, gav1d's decoder read them whole and Pillow accepted them (docs/GAV1D-026-2026-11-11.md).", + // Their canaries run inside them on every machine with python, which is + // the proof the owner asked for first on 2026-11-11: a check that cannot + // fail turns them red where they stand, with no entry to rewrite when the + // code moves. + "TestTheOTLPCheckRefusesMetricsThatDoNotAddUp": "its own canaries: six files of metrics broken one way each - a temporality, the empty negative buckets, a bucket, a date, a counter that falls, exemplars nobody asked for - " + + "each refused with the reason that names it, beside two healthy files that pass. On 2026-11-11 one canary was refused for another reason and the guard went red, which is how it found that a count one more never reaches the check of the buckets.", + "TestTheExponentialBucketsEndWhereTheirBoundsSay": "its own canary: the table with one entry a microsecond high and then a microsecond low, each refused at that entry alone, before the table itself is asked.", + "TestEveryOTLPPitfallMeetsItsReferenceTool": "its own canary: a file of ids in capitals handed to the oracle as ids in base64, which the Collector reads whole where it was told to expect nothing back, and the oracle has to refuse it. " + + "On 2026-11-11 the guard passed with the Collector 0.165.0 on Windows over 34 files, every pitfall in every kind of telemetry, in 124 s.", // Runs only in the CI job that has the Collector, so a mutation job without // one would meet a skip. What the guard adds to the structural check is the // oracle itself, and the oracle was handed three files it has to refuse. diff --git a/internal/guard/otlp_test.go b/internal/guard/otlp_test.go index f52343ad..05190b37 100644 --- a/internal/guard/otlp_test.go +++ b/internal/guard/otlp_test.go @@ -48,8 +48,71 @@ var otlpCases = []map[string]string{ {otlp.Signal: otlp.Logs, otlp.TraceContext: "false"}, {otlp.Signal: otlp.Logs, otlp.Batch: "3", otlp.Rate: "1000", otlp.LevelMix: "errors", otlp.TraceContext: "false"}, {otlp.Signal: otlp.Logs, otlp.Batch: "1", otlp.Formatting: otlp.RecordPerLine, otlp.Start: "2199-12-31T23:59:59Z"}, + + // Metrics: every kind on its own, both temporalities where a kind has + // one, exemplars in both, the three layouts, the shortest and the longest + // interval and both ends of start. A collection is longer than the + // smallest file, so the first two sizes end inside one. + {otlp.Signal: otlp.Metrics}, + {otlp.Signal: otlp.Metrics, otlp.Formatting: otlp.Minified}, + {otlp.Signal: otlp.Metrics, otlp.Formatting: otlp.Indented, otlp.Exemplars: "true"}, + {otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Gauge, otlp.Interval: "1"}, + {otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Sum}, + {otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Sum, otlp.Temporality: otlp.Delta, otlp.Interval: "86400"}, + {otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Histogram, otlp.Exemplars: "true"}, + {otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Histogram, otlp.Temporality: otlp.Delta, otlp.Exemplars: "true"}, + {otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.ExponentialHistogram}, + {otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.ExponentialHistogram, otlp.Temporality: otlp.Delta, otlp.Start: "2199-12-31T23:59:59Z"}, + {otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Summary}, + {otlp.Signal: otlp.Metrics, otlp.Temporality: otlp.Delta, otlp.Exemplars: "true", otlp.Start: "2002-01-01T00:00:00Z"}, } +// otlpPitfallCases are the pitfalls, each in every kind of telemetry it can +// reach, and all of them at once. Apart from otlpCases because the Collector +// writes a pitfall back in its own spelling, so the oracle asks them another +// question (TestEveryOTLPPitfallMeetsItsReferenceTool). +var otlpPitfallCases = func() []map[string]string { + signals := []map[string]string{ + {otlp.Batch: "5"}, + {otlp.Signal: otlp.Logs, otlp.Batch: "5"}, + {otlp.Signal: otlp.Metrics, otlp.Exemplars: "true"}, + } + pitfalls := []map[string]string{ + {otlp.IDEncoding: otlp.UpperHex}, + {otlp.IDEncoding: otlp.Base64}, + {otlp.EnumEncoding: otlp.Name}, + {otlp.KeyCase: otlp.Snake}, + {otlp.LargeNumbers: otlp.Number}, + {otlp.BOM: "true"}, + {otlp.TimeOrder: otlp.OutOfOrder}, + {otlp.EmptyEnvelope: "true"}, + {otlp.IDEncoding: otlp.UpperHex, otlp.EnumEncoding: otlp.Name, otlp.KeyCase: otlp.Snake, otlp.LargeNumbers: otlp.Number, + otlp.BOM: "true", otlp.TimeOrder: otlp.OutOfOrder, otlp.EmptyEnvelope: "true"}, + } + var out []map[string]string + for _, s := range signals { + for _, p := range pitfalls { + c := map[string]string{} + for k, v := range s { + c[k] = v + } + for k, v := range p { + c[k] = v + } + out = append(out, c) + } + } + return append(out, + map[string]string{otlp.Batch: "5", otlp.EndBeforeStart: "true", otlp.Depth: "4"}, + map[string]string{otlp.Signal: otlp.Logs, otlp.Batch: "5", otlp.Body: otlp.AnyValue}, + map[string]string{otlp.Signal: otlp.Logs, otlp.Batch: "5", otlp.Body: otlp.AnyValue, otlp.LargeNumbers: otlp.Number, otlp.KeyCase: otlp.Snake}, + map[string]string{otlp.Signal: otlp.Metrics, otlp.NonFinite: "true"}, + map[string]string{otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Gauge, otlp.NonFinite: "true", otlp.TimeOrder: otlp.OutOfOrder}, + map[string]string{otlp.BOM: "true", otlp.Formatting: otlp.Indented}, + map[string]string{otlp.Signal: otlp.Logs, otlp.KeyCase: otlp.Snake, otlp.Formatting: otlp.Minified, otlp.EnumEncoding: otlp.Name}, + ) +}() + // otlpSizes are the sizes every case is written at: the smallest file and the // byte above it, where the padding goes from nothing to one byte, a size of a // few dozen records, and one large enough to end a default batch and open a @@ -72,7 +135,7 @@ func TestAnOTLPFileIsWhatItsSettingsSay(t *testing.T) { dir := t.TempDir() var reqs []oracle.StrictRequest batched := 0 - for i, props := range otlpCases { + for i, props := range append(append([]map[string]string{}, otlpCases...), otlpPitfallCases...) { smallest := d.SmallestAccepted(format.Request{Properties: props}) for _, size := range otlpSizes(smallest) { path := filepath.Join(dir, strconv.Itoa(i)+"_"+strconv.FormatInt(size, 10)+".jsonl") @@ -135,6 +198,61 @@ func TestEveryOTLPSettingSurvivesItsReferenceTool(t *testing.T) { } } +// Every pitfall meets the Collector the way its description says: read and +// written back in the spelling of the specification, or dropped without a +// word - every batch for ids in base64, the first one behind a byte order +// mark, and an empty envelope. Measured on 2026-11-11 and asked again here, +// because those sentences are what a tester reads before trusting the file. +// +// The structural check runs first and has to find each pitfall in its file. +// The oracle spells a line the way the specification asks before comparing, so +// a pitfall the generator forgot would come back the same and pass it. +// +// Named to match ReferenceTool, for the reason the guard above gives. +func TestEveryOTLPPitfallMeetsItsReferenceTool(t *testing.T) { + dir := t.TempDir() + var reqs []oracle.StrictRequest + for i, props := range otlpPitfallCases { + path := filepath.Join(dir, "pitfall"+strconv.Itoa(i)+".jsonl") + size := int64(20000) + if props[otlp.Signal] == otlp.Metrics { + // Three collections, so a first line and a last one both exist. + size = 60000 + } + writeOTLP(t, path, size, uint64(91+i), props) + reqs = append(reqs, oracle.StrictRequest{Format: "otlp", Path: path, Settings: props}) + } + got, available, err := oracle.StrictBatch(t.Context(), reqs) + if !available { + t.Skip("the structural check needs python, and without it no pitfall is known to be in its file") + } + if err != nil { + t.Fatal(err) + } + for i, j := range got { + if j.Verdict != oracle.StrictOK { + t.Fatalf("%v: the structural check does not find the file made as its settings say, so the Collector would be asked about something else: %s %s", + reqs[i].Settings, j.Verdict, j.Reason) + } + } + // The canary first: a file of ids in capitals handed over as ids in + // base64. The Collector reads every batch of it, and the oracle was told + // to expect none back, so it has to say no. + canary := map[string]string{otlp.Batch: "5", otlp.IDEncoding: otlp.Base64} + res := oracle.OTLPPitfalls(reqs[0].Path, canary) + if !res.Available { + t.Skipf("%s is not installed, so no pitfall was read by it - a skip, not a pass", res.Tool) + } + if res.Err == nil { + t.Fatalf("the oracle passed %v told %v, where the Collector reads what it was told it drops - it would pass a pitfall described wrongly", reqs[0].Settings, canary) + } + for _, r := range reqs { + if res := oracle.OTLPPitfalls(r.Path, r.Settings); res.Err != nil { + t.Errorf("%v: %v", r.Settings, res.Err) + } + } +} + // The file receiver of the Collector drops a line longer than 1 MiB without a // word in its log, measured on 2026-11-10: 1 048 576 B was read and 1 048 577 B // was not. So no setting may make such a line, and the largest batch the @@ -152,6 +270,14 @@ func TestNoOTLPLineIsLongerThanTheFileReceiverReads(t *testing.T) { {otlp.Batch: "1000", otlp.Depth: "100"}, {otlp.Batch: "1000", otlp.Signal: otlp.Logs}, {otlp.Batch: "1000", otlp.Signal: otlp.Logs, otlp.LevelMix: "errors"}, + // A line of metrics is one collection, the 41 points of every kind + // together, with the longest of them naming its measurements. + {otlp.Signal: otlp.Metrics, otlp.Exemplars: "true"}, + // The pitfalls that lengthen a record: every key a byte or more + // longer in snake case, enums by their names, bodies that are maps. + {otlp.Batch: "1000", otlp.Depth: "1", otlp.StatusMix: "server-errors", otlp.KeyCase: otlp.Snake, otlp.EnumEncoding: otlp.Name}, + {otlp.Batch: "1000", otlp.Signal: otlp.Logs, otlp.KeyCase: otlp.Snake, otlp.EnumEncoding: otlp.Name, otlp.Body: otlp.AnyValue}, + {otlp.Signal: otlp.Metrics, otlp.Exemplars: "true", otlp.KeyCase: otlp.Snake, otlp.EnumEncoding: otlp.Name, otlp.NonFinite: "true"}, } { bound, err := otlp.LongestLine(props) if err != nil { @@ -171,12 +297,16 @@ func TestNoOTLPLineIsLongerThanTheFileReceiverReads(t *testing.T) { t.Fatal(err) } full := 0 - leaf := map[string]string{"": `{"traceId":`, otlp.Logs: `{"timeUnixNano":`}[props[otlp.Signal]] + leaf := map[string]string{"": `{"traceId":`, otlp.Logs: `{"timeUnixNano":`, otlp.Metrics: `"startTimeUnixNano":`}[props[otlp.Signal]] + if props[otlp.KeyCase] == otlp.Snake { + leaf = map[string]string{"": `{"trace_id":`, otlp.Logs: `{"time_unix_nano":`, otlp.Metrics: `"start_time_unix_nano":`}[props[otlp.Signal]] + } + batch := map[string]int{"": 1000, otlp.Logs: 1000, otlp.Metrics: 41}[props[otlp.Signal]] for _, line := range strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n") { if int64(len(line)) > bound { t.Errorf("%v: a line of %d B, and the format says none is longer than %d B", props, len(line), bound) } - if strings.Count(line, leaf) == 1000 { + if strings.Count(line, leaf) == batch { full++ } } @@ -208,6 +338,25 @@ func TestOTLPRefusesASettingThatHasNothingToShape(t *testing.T) { {map[string]string{otlp.TraceContext: "false"}, "trace_context and signal"}, {map[string]string{otlp.Formatting: otlp.Minified, otlp.Batch: "10"}, "batch and formatting"}, {map[string]string{otlp.Formatting: otlp.Indented, otlp.Batch: "10"}, "batch and formatting"}, + {map[string]string{otlp.Signal: otlp.Metrics, otlp.Batch: "10"}, "batch and signal"}, + {map[string]string{otlp.Signal: otlp.Metrics, otlp.Rate: "10"}, "rate and signal"}, + {map[string]string{otlp.Signal: otlp.Metrics, otlp.Depth: "2"}, "depth and signal"}, + {map[string]string{otlp.Signal: otlp.Metrics, otlp.TraceContext: "false"}, "trace_context and signal"}, + {map[string]string{otlp.MetricKind: otlp.Sum}, "metric_kind and signal"}, + {map[string]string{otlp.Signal: otlp.Logs, otlp.Interval: "5"}, "interval and signal"}, + {map[string]string{otlp.Exemplars: "true"}, "exemplars and signal"}, + {map[string]string{otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Gauge, otlp.Temporality: otlp.Delta}, "temporality and metric_kind"}, + {map[string]string{otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Summary, otlp.Temporality: otlp.Delta}, "temporality and metric_kind"}, + {map[string]string{otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.ExponentialHistogram, otlp.Exemplars: "true"}, "exemplars and metric_kind"}, + {map[string]string{otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Sum, otlp.Exemplars: "true"}, "exemplars and metric_kind"}, + {map[string]string{otlp.Signal: otlp.Logs, otlp.TraceContext: "false", otlp.IDEncoding: otlp.Base64}, "id_encoding and trace_context"}, + {map[string]string{otlp.Signal: otlp.Metrics, otlp.IDEncoding: otlp.UpperHex}, "id_encoding and exemplars"}, + {map[string]string{otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Gauge, otlp.EnumEncoding: otlp.Name}, "enum_encoding and metric_kind"}, + {map[string]string{otlp.Signal: otlp.Logs, otlp.EndBeforeStart: "true"}, "end_before_start and signal"}, + {map[string]string{otlp.Body: otlp.AnyValue}, "body and signal"}, + {map[string]string{otlp.NonFinite: "true"}, "non_finite and signal"}, + {map[string]string{otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Sum, otlp.NonFinite: "true"}, "non_finite and metric_kind"}, + {map[string]string{otlp.Formatting: otlp.Minified, otlp.EmptyEnvelope: "true"}, "empty_envelope and formatting"}, } { _, err := d.Generator.Plan(format.Request{Bytes: 100000, Seed: 1, Properties: c.props}) var refused *format.PropertyValueError @@ -224,14 +373,23 @@ func TestOTLPRefusesASettingThatHasNothingToShape(t *testing.T) { for _, p := range d.Properties { everyDefault[p.Name] = p.Default } - for _, signal := range []string{otlp.Traces, otlp.Logs} { + for _, signal := range []string{otlp.Traces, otlp.Logs, otlp.Metrics} { everyDefault[otlp.Signal] = signal for _, layout := range []string{otlp.RecordPerLine, otlp.Minified, otlp.Indented} { everyDefault[otlp.Formatting] = layout - if _, err := d.Generator.Plan(format.Request{Bytes: 100000, Seed: 1, Properties: everyDefault}); err != nil { - t.Errorf("signal %s laid out %s with every other setting sent at its default was refused: %v.\n"+ - "Reason: a window sends every setting it draws, so a default that arrived is not a choice", - signal, layout, err) + kinds := []string{otlp.Mixed} + if signal == otlp.Metrics { + // Every kind, because two of the settings a window sends are + // refused beside some kinds when they are chosen. + kinds = []string{otlp.Gauge, otlp.Sum, otlp.Histogram, otlp.ExponentialHistogram, otlp.Summary, otlp.Mixed} + } + for _, kind := range kinds { + everyDefault[otlp.MetricKind] = kind + if _, err := d.Generator.Plan(format.Request{Bytes: 100000, Seed: 1, Properties: everyDefault}); err != nil { + t.Errorf("signal %s laid out %s, metric_kind %s, with every other setting sent at its default was refused: %v.\n"+ + "Reason: a window sends every setting it draws, so a default that arrived is not a choice", + signal, layout, kind, err) + } } } } diff --git a/internal/guard/otlpmetrics_test.go b/internal/guard/otlpmetrics_test.go new file mode 100644 index 00000000..9a382d56 --- /dev/null +++ b/internal/guard/otlpmetrics_test.go @@ -0,0 +1,167 @@ +package guard + +import ( + "bytes" + "math/big" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format/otlp" + "github.com/donislawdev/TestingFilesGenerator/internal/oracle" +) + +// The structural check of a file of metrics adds the points up: a count +// against its buckets, a counter against the collection before, a date +// against the interval, the temporality against the setting, and the +// exemplars against the buckets the collection measured in. Each of those is +// broken here in a file that passes, and the check has to refuse it with the +// reason that names what was broken - refused for another reason would mean +// the check it was aimed at never ran. +// +// These are the canaries of that check. They run on every machine with +// python, where a mutation of it would run on a schedule. +func TestTheOTLPCheckRefusesMetricsThatDoNotAddUp(t *testing.T) { + dir := t.TempDir() + mixed := map[string]string{otlp.Signal: otlp.Metrics} + named := map[string]string{otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Histogram, otlp.Exemplars: "true"} + unnamed := map[string]string{otlp.Signal: otlp.Metrics, otlp.MetricKind: otlp.Histogram} + good := writeOTLP(t, filepath.Join(dir, "good.jsonl"), 120000, 9, mixed) + withExemplars := writeOTLP(t, filepath.Join(dir, "named.jsonl"), 60000, 9, named) + if bytes.Count(good, []byte("\n")) < 3 { + t.Fatal("the file holds fewer than three collections, so no counter has a collection before it to be compared with") + } + + write := func(name string, body []byte) string { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + return path + } + // first changes the first match of pattern in the file, the group it + // holds handed to change, and fails the guard when there is none. + first := func(body []byte, pattern string, change func(string) string) []byte { + re := regexp.MustCompile(pattern) + at := re.FindSubmatchIndex(body) + if at == nil { + t.Fatalf("nothing in the file matches %s, so there is nothing to break", pattern) + } + out := append([]byte{}, body[:at[2]]...) + out = append(out, change(string(body[at[2]:at[3]]))...) + return append(out, body[at[3]:]...) + } + plus := func(n int64) func(string) string { + return func(s string) string { + v, _ := strconv.ParseInt(s, 10, 64) + return strconv.FormatInt(v+n, 10) + } + } + lines := bytes.SplitAfter(good, []byte("\n")) + secondFalling := append(append([]byte{}, lines[0]...), + first(lines[1], `"name":"jvm.class.loaded".*?"asInt":"(\d+)"`, func(string) string { return "1" })...) + for _, l := range lines[2:] { + secondFalling = append(secondFalling, l...) + } + + cases := []struct { + what, because string + body []byte + settings map[string]string + }{ + {"a temporality other than the one asked", "states the temporality 1", + first(good, `"aggregationTemporality":(2),"isMonotonic"`, func(string) string { return "1" }), mixed}, + {"an exponential histogram without its empty negative buckets", "at scale 3", + bytes.Replace(good, []byte(`"negative":{},`), nil, 1), mixed}, + // A bucket rather than the count: a count one more is refused + // earlier, by the sum lying outside the least and the most times it, + // and this check would never be asked (measured 2026-11-11). + {"a bucket holding one more than the count says", "and its count says", + first(good, `"bucketCounts":\["(\d+)"`, plus(1)), mixed}, + {"a collection dated a second late", "is dated", + first(good, `"timeUnixNano":"(\d+)"`, plus(1_000_000_000)), mixed}, + {"a counter below what it reached a collection before", "below the", + secondFalling, mixed}, + {"exemplars where none were asked", "exemplars=false asks for none", withExemplars, unnamed}, + } + reqs := []oracle.StrictRequest{ + {Format: "otlp", Path: filepath.Join(dir, "good.jsonl"), Settings: mixed}, + {Format: "otlp", Path: filepath.Join(dir, "named.jsonl"), Settings: named}, + } + for i, c := range cases { + reqs = append(reqs, oracle.StrictRequest{Format: "otlp", Path: write("broken"+strconv.Itoa(i)+".jsonl", c.body), Settings: c.settings}) + } + got, available, err := oracle.StrictBatch(t.Context(), reqs) + if !available { + t.Skip("the structural check needs python") + } + if err != nil { + t.Fatal(err) + } + for i := range 2 { + if got[i].Verdict != oracle.StrictOK { + t.Fatalf("%s is itself refused, so no refusal below says anything: %s", filepath.Base(reqs[i].Path), got[i].Reason) + } + } + for i, c := range cases { + if j := got[i+2]; j.Verdict != oracle.StrictFail { + t.Errorf("%s: the checker answered %s (%s), and it has to refuse", c.what, j.Verdict, j.Reason) + } else if !strings.Contains(j.Reason, c.because) { + t.Errorf("%s: refused, but for another reason - %q, where it has to say %q", c.what, j.Reason, c.because) + } + } +} + +// The exponential buckets are written out as whole microseconds rather than +// worked out with a logarithm, so a duration falls in the same bucket on +// every machine. Each entry has to be the largest whole number of +// microseconds not above 2^(k/8) seconds, which is asked here in whole +// numbers: f^8 <= 2^k * 10^48 < (f+1)^8. +// +// The canary first: a table with one entry a microsecond high and one a +// microsecond low, which the same question has to refuse. +func TestTheExponentialBucketsEndWhereTheirBoundsSay(t *testing.T) { + scale, first, tops := otlp.BucketTops() + if scale != 3 { + t.Fatalf("the table is read at scale %d and this guard asks about eight buckets a doubling, scale 3", scale) + } + for _, off := range []int64{1, -1} { + bad := append([]int64{}, tops...) + bad[len(bad)/2] += off + if wrong := wrongBucketTops(first, bad); len(wrong) != 1 { + t.Fatalf("a table with one entry moved by %d is refused at %d entries, not at the one moved", off, len(wrong)) + } + } + if len(tops) != -first+8 { + t.Errorf("the table holds %d buckets from index %d, and a bucket ending at 2 s is index 7, so it has %d", len(tops), first, -first+8) + } + for _, i := range wrongBucketTops(first, tops) { + t.Errorf("bucket %d ends at %d us, which is not the largest whole number of microseconds under 2^(%d/8) s", first+i, tops[i], first+i+1) + } +} + +// wrongBucketTops is the places in a table of bucket ends where an entry is +// not the floor of 10^6 * 2^(k/8). +func wrongBucketTops(first int, tops []int64) []int { + var wrong []int + limit := new(big.Int).Exp(big.NewInt(10), big.NewInt(48), nil) + for i, f := range tops { + k := first + i + 1 + low := new(big.Int).Exp(big.NewInt(f), big.NewInt(8), nil) + high := new(big.Int).Exp(big.NewInt(f+1), big.NewInt(8), nil) + bound := new(big.Int).Set(limit) + if k >= 0 { + bound.Lsh(bound, uint(k)) + } else { + low.Lsh(low, uint(-k)) + high.Lsh(high, uint(-k)) + } + if low.Cmp(bound) > 0 || high.Cmp(bound) <= 0 { + wrong = append(wrong, i) + } + } + return wrong +} diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index 90afd5c6..81de68f3 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -204,13 +204,27 @@ var reachableFromTheWindow = []string{ "property:geojson.vertices", "property:geojson.winding", "property:otlp.batch", + "property:otlp.body", + "property:otlp.bom", "property:otlp.depth", + "property:otlp.empty_envelope", + "property:otlp.end_before_start", + "property:otlp.enum_encoding", + "property:otlp.exemplars", "property:otlp.formatting", + "property:otlp.id_encoding", + "property:otlp.interval", + "property:otlp.key_case", + "property:otlp.large_numbers", "property:otlp.level_mix", + "property:otlp.metric_kind", + "property:otlp.non_finite", "property:otlp.rate", "property:otlp.signal", "property:otlp.start", "property:otlp.status_mix", + "property:otlp.temporality", + "property:otlp.time_order", "property:otlp.trace_context", "property:gif.frames", "property:gif.height", diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json index 359acb5b..01fd83f6 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -725,6 +725,51 @@ "bytes": 8192, "sha256": "1380382a49470452d1662988429244b75a801dd50b8b7d0af21f773aee32f62d", "measured_on": "2026-11-11" + }, + "otlp_40kib_pitfalls_metrics": { + "bytes": 40960, + "sha256": "d5ad4a69f29fb9eec5e58174d30f68c84487eb18e713557b6e1b1f7678b12bd1", + "measured_on": "2026-11-11" + }, + "otlp_8kib_pitfalls_logs": { + "bytes": 8192, + "sha256": "ff58422e4bbf6711680b50090c80295ea62c741a08f3f415125810c74dbfe405", + "measured_on": "2026-11-11" + }, + "otlp_8kib_pitfalls_spans": { + "bytes": 8192, + "sha256": "c6494ee30e3be319b0e9571da4fdfe23e353bcba4c2d14d20306af417aa0c1c9", + "measured_on": "2026-11-11" + }, + "otlp_20kib_metrics_summary_indented": { + "bytes": 20480, + "sha256": "2db2f14bd882fcee5cc669db6045ca30618e93e2177f9eb50fdb3a0e1e132925", + "measured_on": "2026-11-11" + }, + "otlp_40kib_metrics_histogram_exemplars": { + "bytes": 40960, + "sha256": "2ce27d33d20f216830f041718565aed4be909b5e91892ab5b045ddc29d5a2315", + "measured_on": "2026-11-11" + }, + "otlp_8kib_metrics": { + "bytes": 8192, + "sha256": "4e1ddcb1ceb401a5aea8897ee25434753e3613cc823a173767df1dd4f375cccd", + "measured_on": "2026-11-11" + }, + "otlp_8kib_metrics_exponential": { + "bytes": 8192, + "sha256": "8a40f7f40e96d85f4123e0d91ddf1e97acb21f10e0d8d3802a3768f0cdae2280", + "measured_on": "2026-11-11" + }, + "otlp_8kib_metrics_gauge": { + "bytes": 8192, + "sha256": "51f986adf4774fa178f4d79ea64f3d4af1bcf74cf373308c61adf5477d39289b", + "measured_on": "2026-11-11" + }, + "otlp_8kib_metrics_sum_delta": { + "bytes": 8192, + "sha256": "09453a63d9e92e5e78fc0bf62352b00bfbcace558584a11a7bf04db54de8e7f0", + "measured_on": "2026-11-11" } }, "remeasured": [ diff --git a/internal/gui/text/labels.go b/internal/gui/text/labels.go index 65966eaf..4b931108 100644 --- a/internal/gui/text/labels.go +++ b/internal/gui/text/labels.go @@ -41,16 +41,20 @@ func EnglishLabel(key string) string { // window for the first time does not know. Inline is what MIME calls a picture // the text shows (the owner's decision of 2026-10-14, // docs/EML-2-2026-10-13.md section 8). The key is still one hover away. bom is -// an abbreviation, and spelled out of the key it read "Bom" (O278). +// an abbreviation, and spelled out of the key it read "Bom" (O278). id is one +// too, and non_finite read "Non finite", which names the values it writes in +// no words a reader of OTLP knows. // // Keyed by the recipe key rather than declared on a format's setting, because // a label belongs to the key: every format, preset and tool declaring one key // shares one Label entry in the catalogue. var respelled = map[string]string{ "bom": "BOM", + "id_encoding": "ID encoding", "inline": "Pictures", "inline_format": "Picture format", "inline_size": "Picture size", + "non_finite": "NaN and Infinity", } // RespelledKeys are the keys EnglishLabel does not spell out of themselves, diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index 60737cf8..8db895f0 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -174,6 +174,11 @@ "hash": "sha256-27747689baf9", "other": "alternative" }, + "Choice.body.any-value": { + "description": "One entry of the body list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-cc8f80439173", + "other": "any-value" + }, "Choice.body.html": { "description": "One entry of the body list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-e5e239560bc1", @@ -184,6 +189,11 @@ "hash": "sha256-a116c9ed46d6", "other": "plain" }, + "Choice.body.text": { + "description": "One entry of the body list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-982d9e3eb996", + "other": "text" + }, "Choice.characters.ascii": { "description": "One entry of the characters list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-c543ece81605", @@ -424,6 +434,16 @@ "hash": "sha256-04f8996da763", "other": "user" }, + "Choice.enum_encoding.name": { + "description": "One entry of the enum_encoding list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-82a3537ff0db", + "other": "name" + }, + "Choice.enum_encoding.number": { + "description": "One entry of the enum_encoding list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-12886f9d0005", + "other": "number" + }, "Choice.expected.accept": { "description": "One entry of the list of what the system under test should do with a file. The manifest writes the value itself.", "hash": "sha256-c125d0397b60", @@ -849,6 +869,21 @@ "hash": "sha256-941b7ecd47e5", "other": "utf8" }, + "Choice.id_encoding.base64": { + "description": "One entry of the id_encoding list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-371a286d5872", + "other": "base64" + }, + "Choice.id_encoding.hex": { + "description": "One entry of the id_encoding list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-128df13c1e54", + "other": "hex" + }, + "Choice.id_encoding.upper-hex": { + "description": "One entry of the id_encoding list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-18b09d853661", + "other": "upper-hex" + }, "Choice.ids.mixed": { "description": "One entry of the ids list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-3f8fee624f43", @@ -884,6 +919,26 @@ "hash": "sha256-3e8286044388", "other": "v6" }, + "Choice.key_case.camel": { + "description": "One entry of the key_case list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-4812585e9449", + "other": "camel" + }, + "Choice.key_case.snake": { + "description": "One entry of the key_case list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-538d7d9fe78e", + "other": "snake" + }, + "Choice.large_numbers.number": { + "description": "One entry of the large_numbers list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-12886f9d0005", + "other": "number" + }, + "Choice.large_numbers.text": { + "description": "One entry of the large_numbers list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-982d9e3eb996", + "other": "text" + }, "Choice.layout.newsletter": { "description": "One entry of the layout list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-dd6f465f6810", @@ -949,6 +1004,36 @@ "hash": "sha256-3316348dbadf", "other": "read" }, + "Choice.metric_kind.exponential-histogram": { + "description": "One entry of the metric_kind list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-9e08a9dd9e02", + "other": "exponential-histogram" + }, + "Choice.metric_kind.gauge": { + "description": "One entry of the metric_kind list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-a90fd9a9a1e6", + "other": "gauge" + }, + "Choice.metric_kind.histogram": { + "description": "One entry of the metric_kind list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-56f0e5b2847c", + "other": "histogram" + }, + "Choice.metric_kind.mixed": { + "description": "One entry of the metric_kind list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-3f8fee624f43", + "other": "mixed" + }, + "Choice.metric_kind.sum": { + "description": "One entry of the metric_kind list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-09f5ffef2830", + "other": "sum" + }, + "Choice.metric_kind.summary": { + "description": "One entry of the metric_kind list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-761b7ad8ad43", + "other": "summary" + }, "Choice.orientation.landscape": { "description": "One entry of the orientation list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-acb955df82b8", @@ -1054,6 +1139,11 @@ "hash": "sha256-98f38f12db22", "other": "logs" }, + "Choice.signal.metrics": { + "description": "One entry of the signal list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-177a7ea3611f", + "other": "metrics" + }, "Choice.signal.traces": { "description": "One entry of the signal list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-b5ac69fc467c", @@ -1104,6 +1194,16 @@ "hash": "sha256-140bedbf9c3f", "other": "none" }, + "Choice.temporality.cumulative": { + "description": "One entry of the temporality list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-2dfda8e4279b", + "other": "cumulative" + }, + "Choice.temporality.delta": { + "description": "One entry of the temporality list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-4f4a9410ffcd", + "other": "delta" + }, "Choice.text_encoding.7bit": { "description": "One entry of the text_encoding list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-af6ec1d82852", @@ -1144,6 +1244,16 @@ "hash": "sha256-2fcf76a4c3c7", "other": "unicode" }, + "Choice.time_order.in-order": { + "description": "One entry of the time_order list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-d733f4e84323", + "other": "in-order" + }, + "Choice.time_order.out-of-order": { + "description": "One entry of the time_order list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-da48228bd1a4", + "other": "out-of-order" + }, "Choice.timestamps.advancing": { "description": "One entry of the timestamps list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-3656fa4d5539", @@ -1586,48 +1696,118 @@ }, "Detail.format/otlp.batch": { "description": "The sentence under the batch setting of the otlp format, after what it takes.", - "hash": "sha256-3c2c865d96f5", - "other": "How many spans or log records each line holds. 512 is the batch an OpenTelemetry SDK sends by default. Only record-per-line has batches, and beside another layout a batch is refused." + "hash": "sha256-793fcaf7d3f2", + "other": "How many spans or log records each line holds. 512 is the batch an OpenTelemetry SDK sends by default. Only record-per-line has batches, and beside another layout or beside signal metrics, where a line holds one collection, a batch is refused." + }, + "Detail.format/otlp.body": { + "description": "The sentence under the body setting of the otlp format, after what it takes.", + "hash": "sha256-483c91e6d770", + "other": "What the body of a log record holds. text is a sentence. any-value gives the seven kinds of value OTLP has in turn - text, a boolean, the whole number 2^53 + 1, a fraction, a list, a map and bytes - and the OpenTelemetry Collector reads them and writes them back unchanged. With large_numbers number, a reader that holds numbers as doubles reads that whole number one less. It shapes log records, and beside signal traces or metrics it is refused." + }, + "Detail.format/otlp.bom": { + "description": "The sentence under the bom setting of the otlp format, after what it takes.", + "hash": "sha256-a2d6a88583d6", + "other": "Whether the file opens with a byte order mark. A reader that has to guess the encoding needs one, and a reader that does not expect it shows it as stray characters at the start of the file." }, "Detail.format/otlp.depth": { "description": "The sentence under the depth setting of the otlp format, after what it takes.", - "hash": "sha256-d1caceabbb7a", - "other": "How many spans each trace has, each one the child of the one before it. 1 leaves every span without a parent. It shapes spans, and beside signal logs it is refused." + "hash": "sha256-569c03730a30", + "other": "How many spans each trace has, each one the child of the one before it. 1 leaves every span without a parent. It shapes spans, and beside signal logs or metrics it is refused." + }, + "Detail.format/otlp.empty_envelope": { + "description": "The sentence under the empty_envelope setting of the otlp format, after what it takes.", + "hash": "sha256-62ac042d001f", + "other": "Whether the file opens with a line holding a batch with nothing in it, which the OTLP specification asks senders not to write. The file receiver of the OpenTelemetry Collector reads the line and writes nothing for it. Only record-per-line has a line to spare, and beside another layout it is refused." + }, + "Detail.format/otlp.end_before_start": { + "description": "The sentence under the end_before_start setting of the otlp format, after what it takes.", + "hash": "sha256-0a13a989796c", + "other": "Whether every span is dated with its end before its start, the two times changed places. The OpenTelemetry Collector reads such a span and writes it back unchanged. It shapes spans, and beside signal logs or metrics it is refused." + }, + "Detail.format/otlp.enum_encoding": { + "description": "The sentence under the enum_encoding setting of the otlp format, after what it takes.", + "hash": "sha256-428ff808c144", + "other": "How the kind of a span, the code of its status, the severity of a log record and the temporality of a sum or a histogram are written. number is what the OTLP specification asks for. name writes names such as SPAN_KIND_SERVER, which the specification forbids. The OpenTelemetry Collector reads them anyway and writes numbers back, and the Rust crate opentelemetry-proto refuses a span written that way." + }, + "Detail.format/otlp.exemplars": { + "description": "The sentence under the exemplars setting of the otlp format, after what it takes.", + "hash": "sha256-ac0f521a7266", + "other": "Whether the durations of requests name the trace and the span a measurement was taken in, one for each bucket a collection measured in. The file holds no spans, so those ids match nothing in it. Only http.server.request.duration has them, and beside a metric_kind without it exemplars are refused." }, "Detail.format/otlp.formatting": { "description": "The sentence under the formatting setting of the otlp format, after what it takes.", "hash": "sha256-b094d2e4c7a2", "other": "How the file is laid out. record-per-line puts one batch on each line, which is what the OTLP file exporter writes and the file receiver of the OpenTelemetry Collector reads, and the file name ends in .jsonl. minified and indented make the whole file one request, the body to post to an OTLP endpoint, and the file name ends in .json. Posted as a body, a file of several lines loses every line after the first in the Collector, and its file receiver reads nothing from an indented file." }, + "Detail.format/otlp.id_encoding": { + "description": "The sentence under the id_encoding setting of the otlp format, after what it takes.", + "hash": "sha256-8dac2112d3f4", + "other": "How trace and span ids are written. hex is what the OTLP specification asks for. upper-hex writes the same digits in capitals, which the specification allows, and the OpenTelemetry Collector writes them back in lower case. base64 writes them the way the JSON mapping of protobuf writes bytes, which the specification forbids - the file receiver of the Collector drops every batch without a word and its HTTP receiver answers 400. A reader built on the protobuf library reads base64 ids right and hex ids as something else." + }, + "Detail.format/otlp.interval": { + "description": "The sentence under the interval setting of the otlp format, after what it takes.", + "hash": "sha256-2ce89e34952b", + "other": "How long a service waits between two collections of its metrics. 60 is what an SDK uses by default. Every point of a collection is dated at the same moment, and in record-per-line a line holds one collection." + }, + "Detail.format/otlp.key_case": { + "description": "The sentence under the key_case setting of the otlp format, after what it takes.", + "hash": "sha256-4f17fdd9113b", + "other": "How the keys of the file are written. camel is what the OTLP specification asks for, such as traceId. snake writes the names the .proto files give the fields, such as trace_id, which the specification calls not valid. The OpenTelemetry Collector reads them and writes camel case back, and the Rust crate opentelemetry-proto refuses the file." + }, + "Detail.format/otlp.large_numbers": { + "description": "The sentence under the large_numbers setting of the otlp format, after what it takes.", + "hash": "sha256-da573f0e1080", + "other": "How times and the other numbers of 64 bits are written. text is what the OTLP specification asks a writer for, such as \"1785585600000000000\". number writes them as JSON numbers, which a reader is told to take as well, and the OpenTelemetry Collector does. A reader that holds every JSON number as a double, as JavaScript does, gets every time to the nearest 256 nanoseconds." + }, "Detail.format/otlp.level_mix": { "description": "The sentence under the level_mix setting of the otlp format, after what it takes.", - "hash": "sha256-96679b162c65", - "other": "Which severities the log records carry. Realistic is mostly INFO with some of the others. It shapes log records, and beside signal traces it is refused." + "hash": "sha256-013335bf03aa", + "other": "Which severities the log records carry. Realistic is mostly INFO with some of the others. It shapes log records, and beside signal traces or metrics it is refused." + }, + "Detail.format/otlp.metric_kind": { + "description": "The sentence under the metric_kind setting of the otlp format, after what it takes.", + "hash": "sha256-c1a86feb6d4f", + "other": "Which metrics the file holds. gauge writes jvm.cpu.recent_utilization and shop.orders.pending, sum writes jvm.cpu.time, jvm.class.loaded and jvm.memory.used, histogram writes http.server.request.duration in the buckets the OpenTelemetry conventions name, exponential-histogram writes jvm.gc.duration in buckets that double every eight, and summary writes http.server.requests as three percentiles, the way Micrometer writes a timer. mixed writes all of them. gauge and sum each hold whole numbers and fractions." + }, + "Detail.format/otlp.non_finite": { + "description": "The sentence under the non_finite setting of the otlp format, after what it takes.", + "hash": "sha256-3cbadc5f5abb", + "other": "Whether jvm.cpu.recent_utilization reports values that are not numbers: NaN in the second of every four collections, and Infinity and -Infinity in turn in the fourth, written as text the way the JSON mapping of protobuf writes them. The OpenTelemetry Collector reads them and writes them back unchanged. Beside a metric_kind without that gauge it is refused." }, "Detail.format/otlp.rate": { "description": "The sentence under the rate setting of the otlp format, after what it takes.", - "hash": "sha256-ce402f8f7753", - "other": "How many traces or log records begin each second." + "hash": "sha256-7e71b4f3b48f", + "other": "How many traces or log records begin each second. Metrics are collected every interval instead, and beside signal metrics a rate is refused." }, "Detail.format/otlp.signal": { "description": "The sentence under the signal setting of the otlp format, after what it takes.", - "hash": "sha256-a0541e2d1349", - "other": "Which kind of telemetry the file holds. traces writes spans and logs writes log records. A file holds one kind, as the OTLP file format asks." + "hash": "sha256-5d461667592e", + "other": "Which kind of telemetry the file holds. traces writes spans, logs writes log records and metrics writes the measurements of a service, collected every interval. A file holds one kind, as the OTLP file format asks." }, "Detail.format/otlp.start": { "description": "The sentence under the start setting of the otlp format, after what it takes.", - "hash": "sha256-a8823ab3b44e", - "other": "The moment the first span or log record is dated. The later ones follow it at the rate. Change it when the system that receives the file refuses or hides data that is too old." + "hash": "sha256-f6d93ca7613b", + "other": "The moment the first span or log record is dated, and the moment a service writing metrics started. The later ones follow it at the rate or every interval. Change it when the system that receives the file refuses or hides data that is too old." }, "Detail.format/otlp.status_mix": { "description": "The sentence under the status_mix setting of the otlp format, after what it takes.", - "hash": "sha256-06462e0e2639", - "other": "Which response codes the requests end with. A code of 500 or above marks its span as an error, as the HTTP conventions of OpenTelemetry ask, and every other code leaves the status unset. It shapes spans, and beside signal logs it is refused." + "hash": "sha256-bb5ae4b2ee09", + "other": "Which response codes the requests end with. A code of 500 or above marks its span as an error, as the HTTP conventions of OpenTelemetry ask, and every other code leaves the status unset. It shapes spans, and beside signal logs or metrics it is refused." + }, + "Detail.format/otlp.temporality": { + "description": "The sentence under the temporality setting of the otlp format, after what it takes.", + "hash": "sha256-0f6234dad243", + "other": "Whether a sum or a histogram counts everything since the service started or only what came since the collection before. cumulative is what an SDK sends by default. delta leaves jvm.memory.used cumulative, as the delta setting of an SDK does. Beside metric_kind gauge or summary it is refused." + }, + "Detail.format/otlp.time_order": { + "description": "The sentence under the time_order setting of the otlp format, after what it takes.", + "hash": "sha256-223b0e56bd8a", + "other": "Whether every trace, log record or collection is dated later than the one before it. out-of-order swaps the times of every two neighbours, so every second one is dated before the one in front of it. The specification of the OTLP file promises no order, and the OpenTelemetry Collector reads the file and writes it back unchanged. What the system storing the data does with what comes late is the question this file asks." }, "Detail.format/otlp.trace_context": { "description": "The sentence under the trace_context setting of the otlp format, after what it takes.", - "hash": "sha256-634b48d43770", - "other": "Whether every log record names the trace and the span it was written in. The file holds no spans, so those ids match nothing in it. It shapes log records, and beside signal traces it is refused." + "hash": "sha256-918e5211764c", + "other": "Whether every log record names the trace and the span it was written in. The file holds no spans, so those ids match nothing in it. It shapes log records, and beside signal traces or metrics it is refused." }, "Detail.format/pdf.author": { "description": "The sentence under the author setting of the pdf format, after what it takes.", @@ -2209,6 +2389,26 @@ "hash": "sha256-af608b8ed33a", "other": "Pictures in the page" }, + "Group.format/otlp.Log records": { + "description": "The heading over a block of settings of the otlp format.", + "hash": "sha256-1eb4f5f3f4e1", + "other": "Log records" + }, + "Group.format/otlp.Metrics": { + "description": "The heading over a block of settings of the otlp format.", + "hash": "sha256-a58da793c725", + "other": "Metrics" + }, + "Group.format/otlp.Pitfalls": { + "description": "The heading over a block of settings of the otlp format.", + "hash": "sha256-24f33462a118", + "other": "Pitfalls" + }, + "Group.format/otlp.Spans": { + "description": "The heading over a block of settings of the otlp format.", + "hash": "sha256-b3393bfb2a55", + "other": "Spans" + }, "Group.format/pdf.Document properties": { "description": "The heading over a block of settings of the pdf format.", "hash": "sha256-7115a778ab47", @@ -2454,6 +2654,11 @@ "hash": "sha256-7512561d2f68", "other": "Embed" }, + "Label.empty_envelope": { + "description": "The name beside the box of a setting a recipe writes as empty_envelope.", + "hash": "sha256-c06d29fdbae1", + "other": "Empty envelope" + }, "Label.encoding": { "description": "The name beside the box of a setting a recipe writes as encoding.", "hash": "sha256-4d25a47625ce", @@ -2464,6 +2669,11 @@ "hash": "sha256-82f4e719a19c", "other": "Encryption" }, + "Label.end_before_start": { + "description": "The name beside the box of a setting a recipe writes as end_before_start.", + "hash": "sha256-1ce5eed0b42b", + "other": "End before start" + }, "Label.entries": { "description": "The name beside the box of a setting a recipe writes as entries.", "hash": "sha256-7cb76b4af12a", @@ -2489,6 +2699,16 @@ "hash": "sha256-1190635edb6f", "other": "Entry size" }, + "Label.enum_encoding": { + "description": "The name beside the box of a setting a recipe writes as enum_encoding.", + "hash": "sha256-22601c0211e1", + "other": "Enum encoding" + }, + "Label.exemplars": { + "description": "The name beside the box of a setting a recipe writes as exemplars.", + "hash": "sha256-92f6b3f1e81e", + "other": "Exemplars" + }, "Label.expected": { "description": "The name beside the box of a setting a recipe writes as expected.", "hash": "sha256-ca99b7f1b14e", @@ -2564,6 +2784,11 @@ "hash": "sha256-158a3b71a54c", "other": "Holes" }, + "Label.id_encoding": { + "description": "The name beside the box of a setting a recipe writes as id_encoding.", + "hash": "sha256-fd8f6b3477c6", + "other": "ID encoding" + }, "Label.ids": { "description": "The name beside the box of a setting a recipe writes as ids.", "hash": "sha256-d9aa9d4aca97", @@ -2584,11 +2809,21 @@ "hash": "sha256-28a0f4295d32", "other": "Picture size" }, + "Label.interval": { + "description": "The name beside the box of a setting a recipe writes as interval.", + "hash": "sha256-6f45b0005e3b", + "other": "Interval" + }, "Label.ip_version": { "description": "The name beside the box of a setting a recipe writes as ip_version.", "hash": "sha256-29016036fada", "other": "Ip version" }, + "Label.key_case": { + "description": "The name beside the box of a setting a recipe writes as key_case.", + "hash": "sha256-35a6d2a884c1", + "other": "Key case" + }, "Label.keyframe_interval": { "description": "The name beside the box of a setting a recipe writes as keyframe_interval.", "hash": "sha256-e2ebcac3d589", @@ -2599,6 +2834,11 @@ "hash": "sha256-8bfdb93cafaa", "other": "Keywords" }, + "Label.large_numbers": { + "description": "The name beside the box of a setting a recipe writes as large_numbers.", + "hash": "sha256-428384dda8c4", + "other": "Large numbers" + }, "Label.layout": { "description": "The name beside the box of a setting a recipe writes as layout.", "hash": "sha256-a511909161e1", @@ -2639,6 +2879,11 @@ "hash": "sha256-8696622f3441", "other": "Methods" }, + "Label.metric_kind": { + "description": "The name beside the box of a setting a recipe writes as metric_kind.", + "hash": "sha256-c02cc2ddbac5", + "other": "Metric kind" + }, "Label.missing": { "description": "The name beside the box of a setting a recipe writes as missing.", "hash": "sha256-6be36ca49ee8", @@ -2654,6 +2899,11 @@ "hash": "sha256-f342629fc0e1", "other": "Nesting" }, + "Label.non_finite": { + "description": "The name beside the box of a setting a recipe writes as non_finite.", + "hash": "sha256-7bd147326f22", + "other": "NaN and Infinity" + }, "Label.orientation": { "description": "The name beside the box of a setting a recipe writes as orientation.", "hash": "sha256-e3d11242200f", @@ -2779,11 +3029,21 @@ "hash": "sha256-68971283841a", "other": "Subject" }, + "Label.temporality": { + "description": "The name beside the box of a setting a recipe writes as temporality.", + "hash": "sha256-ea2c5cea729a", + "other": "Temporality" + }, "Label.text_encoding": { "description": "The name beside the box of a setting a recipe writes as text_encoding.", "hash": "sha256-f6726ca0b9fe", "other": "Text encoding" }, + "Label.time_order": { + "description": "The name beside the box of a setting a recipe writes as time_order.", + "hash": "sha256-88f72f946cf6", + "other": "Time order" + }, "Label.timestamps": { "description": "The name beside the box of a setting a recipe writes as timestamps.", "hash": "sha256-755f099dbf49", @@ -3269,6 +3529,11 @@ "hash": "sha256-bc51e9e65d79", "other": "rows" }, + "Unit.seconds": { + "description": "What a number counts, after it, as in: from 1 to 20000 seconds. Written the way the language puts a noun after a number.", + "hash": "sha256-59f006d63bd0", + "other": "seconds" + }, "Unit.slides": { "description": "What a number counts, after it, as in: from 1 to 20000 slides. Written the way the language puts a noun after a number.", "hash": "sha256-fd2c8f1dc03c", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index db4b1025..9e3bee46 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -34,8 +34,10 @@ "Choice.bit_depth.32": { "hash": "sha256-e29c9c180c62", "other": "32" }, "Choice.bit_depth.8": { "hash": "sha256-2c624232cdd2", "other": "8" }, "Choice.body.alternative": { "hash": "sha256-27747689baf9", "other": "tekst i html" }, + "Choice.body.any-value": { "hash": "sha256-cc8f80439173", "other": "każdy rodzaj wartości" }, "Choice.body.html": { "hash": "sha256-e5e239560bc1", "other": "html" }, "Choice.body.plain": { "hash": "sha256-a116c9ed46d6", "other": "zwykły tekst" }, + "Choice.body.text": { "hash": "sha256-982d9e3eb996", "other": "tekst" }, "Choice.characters.ascii": { "hash": "sha256-c543ece81605", "other": "ascii" }, "Choice.characters.international": { "hash": "sha256-1e3bb894756f", "other": "międzynarodowe" }, "Choice.charset.iso-8859-2": { "hash": "sha256-d7b4209b5c03", "other": "iso-8859-2" }, @@ -84,6 +86,8 @@ "Choice.entry_owner.root": { "hash": "sha256-4813494d137e", "other": "root" }, "Choice.entry_owner.unset": { "hash": "sha256-6cbf83e08093", "other": "nieustawiony" }, "Choice.entry_owner.user": { "hash": "sha256-04f8996da763", "other": "użytkownik" }, + "Choice.enum_encoding.name": { "hash": "sha256-82a3537ff0db", "other": "nazwa" }, + "Choice.enum_encoding.number": { "hash": "sha256-12886f9d0005", "other": "liczba" }, "Choice.expected.accept": { "hash": "sha256-c125d0397b60", "other": "przyjęcie" }, "Choice.expected.reason.content_malformed": { "hash": "sha256-14484a3d8ff7", "other": "uszkodzona zawartość" }, "Choice.expected.reason.count_limit": { "hash": "sha256-636449b3d71c", "other": "limit liczby plików" }, @@ -169,6 +173,9 @@ "Choice.headers.ascii": { "hash": "sha256-c543ece81605", "other": "ascii" }, "Choice.headers.encoded": { "hash": "sha256-766adc67b02b", "other": "kodowane" }, "Choice.headers.utf8": { "hash": "sha256-941b7ecd47e5", "other": "utf8" }, + "Choice.id_encoding.base64": { "hash": "sha256-371a286d5872", "other": "base64" }, + "Choice.id_encoding.hex": { "hash": "sha256-128df13c1e54", "other": "szesnastkowo" }, + "Choice.id_encoding.upper-hex": { "hash": "sha256-18b09d853661", "other": "szesnastkowo wielkimi literami" }, "Choice.ids.mixed": { "hash": "sha256-3f8fee624f43", "other": "mieszane" }, "Choice.ids.none": { "hash": "sha256-140bedbf9c3f", "other": "brak" }, "Choice.ids.number": { "hash": "sha256-12886f9d0005", "other": "liczba" }, @@ -176,6 +183,10 @@ "Choice.ip_version.mixed": { "hash": "sha256-3f8fee624f43", "other": "mieszane" }, "Choice.ip_version.v4": { "hash": "sha256-8e38a1ea5c68", "other": "v4" }, "Choice.ip_version.v6": { "hash": "sha256-3e8286044388", "other": "v6" }, + "Choice.key_case.camel": { "hash": "sha256-4812585e9449", "other": "camelCase" }, + "Choice.key_case.snake": { "hash": "sha256-538d7d9fe78e", "other": "snake_case" }, + "Choice.large_numbers.number": { "hash": "sha256-12886f9d0005", "other": "liczba" }, + "Choice.large_numbers.text": { "hash": "sha256-982d9e3eb996", "other": "tekst" }, "Choice.layout.newsletter": { "hash": "sha256-dd6f465f6810", "other": "newsletter" }, "Choice.layout.simple": { "hash": "sha256-a7a39b72f297", "other": "prosty" }, "Choice.level_mix.debug": { "hash": "sha256-0b8e9e995d8d", "other": "szczegółowo" }, @@ -189,6 +200,12 @@ "Choice.methods.get": { "hash": "sha256-2998b3232d29", "other": "get" }, "Choice.methods.mixed": { "hash": "sha256-3f8fee624f43", "other": "mieszane" }, "Choice.methods.read": { "hash": "sha256-3316348dbadf", "other": "odczyt" }, + "Choice.metric_kind.exponential-histogram": { "hash": "sha256-9e08a9dd9e02", "other": "histogram wykładniczy" }, + "Choice.metric_kind.gauge": { "hash": "sha256-a90fd9a9a1e6", "other": "wskaźnik" }, + "Choice.metric_kind.histogram": { "hash": "sha256-56f0e5b2847c", "other": "histogram" }, + "Choice.metric_kind.mixed": { "hash": "sha256-3f8fee624f43", "other": "wszystkie" }, + "Choice.metric_kind.sum": { "hash": "sha256-09f5ffef2830", "other": "suma" }, + "Choice.metric_kind.summary": { "hash": "sha256-761b7ad8ad43", "other": "podsumowanie" }, "Choice.orientation.landscape": { "hash": "sha256-acb955df82b8", "other": "poziomo" }, "Choice.orientation.mixed": { "hash": "sha256-3f8fee624f43", "other": "mieszana" }, "Choice.orientation.portrait": { "hash": "sha256-51c5a8296a03", "other": "pionowo" }, @@ -210,6 +227,7 @@ "Choice.rotate.270": { "hash": "sha256-d8d1790737d5", "other": "270" }, "Choice.rotate.90": { "hash": "sha256-69f59c273b6e", "other": "90" }, "Choice.signal.logs": { "hash": "sha256-98f38f12db22", "other": "logi" }, + "Choice.signal.metrics": { "hash": "sha256-177a7ea3611f", "other": "metryki" }, "Choice.signal.traces": { "hash": "sha256-b5ac69fc467c", "other": "ślady" }, "Choice.status_mix.client-errors": { "hash": "sha256-cc47f4e83c84", "other": "błędy klienta" }, "Choice.status_mix.realistic": { "hash": "sha256-9d2f45fd706a", "other": "realistycznie" }, @@ -220,6 +238,8 @@ "Choice.styles.embedded": { "hash": "sha256-9289140b1ac2", "other": "osadzone" }, "Choice.styles.inline": { "hash": "sha256-995cf20a9c45", "other": "w atrybutach" }, "Choice.styles.none": { "hash": "sha256-140bedbf9c3f", "other": "brak" }, + "Choice.temporality.cumulative": { "hash": "sha256-2dfda8e4279b", "other": "narastająco" }, + "Choice.temporality.delta": { "hash": "sha256-4f4a9410ffcd", "other": "przyrostowo" }, "Choice.text_encoding.7bit": { "hash": "sha256-af6ec1d82852", "other": "7bit" }, "Choice.text_encoding.8bit": { "hash": "sha256-e318653d2946", "other": "8bit" }, "Choice.text_encoding.base64": { "hash": "sha256-371a286d5872", "other": "base64" }, @@ -228,6 +248,8 @@ "Choice.text_encoding.literal": { "hash": "sha256-829f8d848b44", "other": "dosłowny" }, "Choice.text_encoding.quoted-printable": { "hash": "sha256-7956a9e2a40b", "other": "quoted-printable" }, "Choice.text_encoding.unicode": { "hash": "sha256-2fcf76a4c3c7", "other": "unicode" }, + "Choice.time_order.in-order": { "hash": "sha256-d733f4e84323", "other": "po kolei" }, + "Choice.time_order.out-of-order": { "hash": "sha256-da48228bd1a4", "other": "nie po kolei" }, "Choice.timestamps.advancing": { "hash": "sha256-3656fa4d5539", "other": "rosnące" }, "Choice.timestamps.fixed": { "hash": "sha256-992a93455c71", "other": "stałe" }, "Choice.unlocated.all": { "hash": "sha256-5ef5ef0364b6", "other": "wszystkie" }, @@ -316,15 +338,29 @@ "Detail.format/mp4.keyframe_interval": { "hash": "sha256-6c717248aaaa", "other": "Jak daleko od siebie są klatki, od których odtwarzacz może zacząć. Krótszy odstęp przyspiesza przewijanie długiego filmu i powiększa plik, bo każda z nich niesie cały obraz dwa razy." }, "Detail.format/mp4.quality": { "hash": "sha256-f6e1c3e1af83", "other": "Ile szczegółów zachowuje obraz. Wyższa wartość wygląda lepiej i zajmuje więcej pliku, zostawiając mniej miejsca na dopełnienie." }, "Detail.format/mp4.width": { "hash": "sha256-3085ac44f0e0", "other": "Jak szeroki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, - "Detail.format/otlp.batch": { "hash": "sha256-3c2c865d96f5", "other": "Ile spanów albo rekordów logów ma każda linia. 512 to paczka, którą SDK OpenTelemetry wysyła domyślnie. Paczki ma tylko układ rekord na wiersz, a przy innym układzie paczka jest odrzucana." }, - "Detail.format/otlp.depth": { "hash": "sha256-d1caceabbb7a", "other": "Ile spanów ma każdy ślad, każdy jako dziecko poprzedniego. 1 zostawia każdy span bez rodzica. Kształtuje spany, a przy sygnale logi jest odrzucane." }, + "Detail.format/otlp.batch": { "hash": "sha256-793fcaf7d3f2", "other": "Ile spanów albo rekordów logów ma każda linia. 512 to paczka, którą SDK OpenTelemetry wysyła domyślnie. Paczki ma tylko układ rekord na wiersz, a przy innym układzie albo przy sygnale metryki, gdzie linia to jeden zbiór, paczka jest odrzucana." }, + "Detail.format/otlp.body": { "hash": "sha256-483c91e6d770", "other": "Co niesie treść rekordu logu. Przy tekst to zdanie. Przy każdy rodzaj wartości rekordy niosą po kolei siedem rodzajów wartości OTLP - tekst, wartość logiczną, liczbę całkowitą 2^53 + 1, ułamek, listę, mapę i bajty - a OpenTelemetry Collector czyta je i zapisuje z powrotem bez zmian. Gdy duże liczby są zapisane jako liczba, czytnik, który trzyma liczby jako double, odczyta tę liczbę całkowitą o jeden mniejszą. Kształtuje rekordy logów, a przy sygnale ślady albo metryki jest odrzucane." }, + "Detail.format/otlp.bom": { "hash": "sha256-a2d6a88583d6", "other": "Czy plik zaczyna się od znacznika kolejności bajtów (BOM). Czytnik, który musi zgadywać kodowanie, go potrzebuje, a czytnik, który się go nie spodziewa, pokazuje go jako obce znaki na początku pliku." }, + "Detail.format/otlp.depth": { "hash": "sha256-569c03730a30", "other": "Ile spanów ma każdy ślad, każdy jako dziecko poprzedniego. 1 zostawia każdy span bez rodzica. Kształtuje spany, a przy sygnale logi albo metryki jest odrzucane." }, + "Detail.format/otlp.empty_envelope": { "hash": "sha256-62ac042d001f", "other": "Czy plik zaczyna się linią z paczką, w której nie ma nic, a specyfikacja OTLP prosi nadawców, żeby takiej nie pisali. Odbiornik plików OpenTelemetry Collectora czyta tę linię i nic za nią nie zapisuje. Linię do oddania ma tylko układ rekord na wiersz, a przy innym układzie jest odrzucane." }, + "Detail.format/otlp.end_before_start": { "hash": "sha256-0a13a989796c", "other": "Czy każdy span ma koniec datowany przed początkiem, czyli oba czasy zamienione miejscami. OpenTelemetry Collector czyta taki span i zapisuje go z powrotem bez zmian. Kształtuje spany, a przy sygnale logi albo metryki jest odrzucane." }, + "Detail.format/otlp.enum_encoding": { "hash": "sha256-428ff808c144", "other": "Jak zapisany jest rodzaj spanu, kod jego statusu, poziom ważności rekordu logu i temporalność sumy albo histogramu. Liczba to zapis, którego wymaga specyfikacja OTLP. Nazwa zapisuje nazwy takie jak SPAN_KIND_SERVER, czego specyfikacja zabrania. OpenTelemetry Collector mimo to je czyta i zapisuje z powrotem liczby, a crate Rust opentelemetry-proto odrzuca tak zapisany span." }, + "Detail.format/otlp.exemplars": { "hash": "sha256-ac0f521a7266", "other": "Czy czasy trwania żądań podają ślad i span, w którym zmierzono pomiar, po jednym na każdy kubełek, w który trafił zbiór. Plik nie ma spanów, więc te identyfikatory nie pasują do niczego w nim. Ma je tylko http.server.request.duration, a przy rodzaju metryk bez niego exemplary są odrzucane." }, "Detail.format/otlp.formatting": { "hash": "sha256-b094d2e4c7a2", "other": "Jak ułożony jest plik. Przy rekord na wiersz każda paczka stoi w osobnej linii, tak jak zapisuje ją eksporter plików OTLP i jak czyta ją odbiornik plików OpenTelemetry Collectora, a nazwa pliku kończy się na .jsonl. Przy zminifikowany i z wcięciami cały plik jest jednym żądaniem, czyli treścią do wysłania na endpoint OTLP, a nazwa pliku kończy się na .json. Wysłany jako treść żądania plik o kilku liniach traci w Collectorze każdą linię po pierwszej, a jego odbiornik plików nie czyta nic z pliku z wcięciami." }, - "Detail.format/otlp.level_mix": { "hash": "sha256-96679b162c65", "other": "Jakie poziomy ważności niosą rekordy logów. Realistycznie daje głównie INFO i trochę pozostałych. Kształtuje rekordy logów, a przy sygnale ślady jest odrzucane." }, - "Detail.format/otlp.rate": { "hash": "sha256-ce402f8f7753", "other": "Ile śladów albo rekordów logów zaczyna się w każdej sekundzie." }, - "Detail.format/otlp.signal": { "hash": "sha256-a0541e2d1349", "other": "Jaki rodzaj telemetrii jest w pliku. Przy ślady plik niesie spany, a przy logi rekordy logów. Plik niesie jeden rodzaj, tak jak wymaga tego format plików OTLP." }, - "Detail.format/otlp.start": { "hash": "sha256-a8823ab3b44e", "other": "Chwila, którą datowany jest pierwszy span albo rekord logu. Kolejne idą po niej w ustawionym tempie. Zmień ją, gdy system, który odbiera plik, odrzuca albo ukrywa zbyt stare dane." }, - "Detail.format/otlp.status_mix": { "hash": "sha256-06462e0e2639", "other": "Jakimi kodami odpowiedzi kończą się żądania. Kod 500 albo wyższy oznacza swój span jako błąd, tak jak wymagają konwencje HTTP OpenTelemetry, a każdy inny kod zostawia status nieustawiony. Kształtuje spany, a przy sygnale logi jest odrzucane." }, - "Detail.format/otlp.trace_context": { "hash": "sha256-634b48d43770", "other": "Czy każdy rekord logu podaje ślad i span, w którym go zapisano. Plik nie ma spanów, więc te identyfikatory nie pasują do niczego w nim. Kształtuje rekordy logów, a przy sygnale ślady jest odrzucane." }, + "Detail.format/otlp.id_encoding": { "hash": "sha256-8dac2112d3f4", "other": "Jak zapisane są identyfikatory śladów i spanów. Szesnastkowo to zapis, którego wymaga specyfikacja OTLP. Szesnastkowo wielkimi literami pisze te same cyfry wielkimi literami, na co specyfikacja pozwala, a OpenTelemetry Collector zapisuje je z powrotem małymi. Base64 pisze je tak, jak mapowanie JSON protobuf zapisuje bajty, czego specyfikacja zabrania - odbiornik plików Collectora bez słowa gubi każdą paczkę, a jego odbiornik HTTP odpowiada 400. Czytnik zbudowany na bibliotece protobuf czyta identyfikatory base64 poprawnie, a szesnastkowe jako coś innego." }, + "Detail.format/otlp.interval": { "hash": "sha256-2ce89e34952b", "other": "Ile czasu usługa czeka między dwoma zbiorami swoich metryk. 60 to wartość, której SDK używa domyślnie. Każdy punkt zbioru ma tę samą datę, a w układzie rekord na wiersz linia to jeden zbiór." }, + "Detail.format/otlp.key_case": { "hash": "sha256-4f17fdd9113b", "other": "Jak zapisane są klucze pliku. camelCase to zapis, którego wymaga specyfikacja OTLP, na przykład traceId. snake_case pisze nazwy, które pliki .proto nadają polom, na przykład trace_id, a specyfikacja nazywa je niepoprawnymi. OpenTelemetry Collector je czyta i zapisuje z powrotem w camelCase, a crate Rust opentelemetry-proto odrzuca plik." }, + "Detail.format/otlp.large_numbers": { "hash": "sha256-da573f0e1080", "other": "Jak zapisane są czasy i inne liczby 64-bitowe. Tekst to zapis, którego specyfikacja OTLP wymaga od nadawcy, na przykład \"1785585600000000000\". Liczba pisze je jako liczby JSON, które czytnik też ma przyjmować, i OpenTelemetry Collector je przyjmuje. Czytnik, który trzyma każdą liczbę JSON jako double, tak jak JavaScript, dostaje każdy czas z dokładnością do 256 nanosekund." }, + "Detail.format/otlp.level_mix": { "hash": "sha256-013335bf03aa", "other": "Jakie poziomy ważności niosą rekordy logów. Realistycznie daje głównie INFO i trochę pozostałych. Kształtuje rekordy logów, a przy sygnale ślady albo metryki jest odrzucane." }, + "Detail.format/otlp.metric_kind": { "hash": "sha256-c1a86feb6d4f", "other": "Jakie metryki są w pliku. Wskaźnik pisze jvm.cpu.recent_utilization i shop.orders.pending, suma pisze jvm.cpu.time, jvm.class.loaded i jvm.memory.used, histogram pisze http.server.request.duration w kubełkach, które podają konwencje OpenTelemetry, histogram wykładniczy pisze jvm.gc.duration w kubełkach, które podwajają się co osiem, a podsumowanie pisze http.server.requests jako trzy percentyle, tak jak Micrometer zapisuje licznik czasu. Wszystkie pisze każdą z nich. Wskaźnik i suma niosą liczby całkowite i ułamki." }, + "Detail.format/otlp.non_finite": { "hash": "sha256-3cbadc5f5abb", "other": "Czy jvm.cpu.recent_utilization zgłasza wartości, które nie są liczbami: NaN w drugim z każdych czterech zbiorów, a w czwartym na zmianę Infinity i -Infinity, zapisane jako tekst, tak jak mapowanie JSON protobuf je zapisuje. OpenTelemetry Collector je czyta i zapisuje z powrotem bez zmian. Przy rodzaju metryk bez tego wskaźnika jest odrzucane." }, + "Detail.format/otlp.rate": { "hash": "sha256-7e71b4f3b48f", "other": "Ile śladów albo rekordów logów zaczyna się w każdej sekundzie. Metryki są zbierane co odstęp zbiorów, a przy sygnale metryki tempo jest odrzucane." }, + "Detail.format/otlp.signal": { "hash": "sha256-5d461667592e", "other": "Jaki rodzaj telemetrii jest w pliku. Przy ślady plik niesie spany, przy logi rekordy logów, a przy metryki pomiary usługi zbierane co odstęp zbiorów. Plik niesie jeden rodzaj, tak jak wymaga tego format plików OTLP." }, + "Detail.format/otlp.start": { "hash": "sha256-f6d93ca7613b", "other": "Chwila, którą datowany jest pierwszy span albo rekord logu, i chwila startu usługi, która pisze metryki. Kolejne idą po niej w ustawionym tempie albo co odstęp zbiorów. Zmień ją, gdy system, który odbiera plik, odrzuca albo ukrywa zbyt stare dane." }, + "Detail.format/otlp.status_mix": { "hash": "sha256-bb5ae4b2ee09", "other": "Jakimi kodami odpowiedzi kończą się żądania. Kod 500 albo wyższy oznacza swój span jako błąd, tak jak wymagają konwencje HTTP OpenTelemetry, a każdy inny kod zostawia status nieustawiony. Kształtuje spany, a przy sygnale logi albo metryki jest odrzucane." }, + "Detail.format/otlp.temporality": { "hash": "sha256-0f6234dad243", "other": "Czy suma albo histogram liczy wszystko od startu usługi, czy tylko to, co przyszło od poprzedniego zbioru. Narastająco to zapis, który SDK wysyła domyślnie. Przyrostowo zostawia jvm.memory.used narastająco, tak jak robi to SDK poproszone o temporalność przyrostową. Przy rodzaju metryk wskaźnik albo podsumowanie jest odrzucane." }, + "Detail.format/otlp.time_order": { "hash": "sha256-223b0e56bd8a", "other": "Czy każdy ślad, rekord logu albo zbiór ma datę późniejszą niż poprzedni. Nie po kolei zamienia czasy każdych dwóch sąsiadów, więc co drugi jest datowany przed tym, który stoi przed nim. Specyfikacja pliku OTLP nie obiecuje kolejności, a OpenTelemetry Collector czyta plik i zapisuje go z powrotem bez zmian. Pytaniem, które zadaje ten plik, jest to, co system przechowujący dane zrobi z tym, co przychodzi późno." }, + "Detail.format/otlp.trace_context": { "hash": "sha256-918e5211764c", "other": "Czy każdy rekord logu podaje ślad i span, w którym go zapisano. Plik nie ma spanów, więc te identyfikatory nie pasują do niczego w nim. Kształtuje rekordy logów, a przy sygnale ślady albo metryki jest odrzucane." }, "Detail.format/pdf.author": { "hash": "sha256-33b1ee363d86", "other": "Autor we właściwościach dokumentu. Gdy pole zostaje puste, dokument nie podaje żadnego." }, "Detail.format/pdf.created": { "hash": "sha256-76abcd78cc63", "other": "Kiedy według dokumentu powstał. Czas zapisany bez strefy zostaje bez niej, a none pomija datę." }, "Detail.format/pdf.creator": { "hash": "sha256-57ed4066a447", "other": "Program, w którym według dokumentu go napisano, na przykład Microsoft Word. Gdy pole zostaje puste, dokument nie podaje żadnego." }, @@ -441,6 +477,10 @@ "Group.format/eml.Attached files": { "hash": "sha256-ecc5a28d1f5e", "other": "Załączone pliki" }, "Group.format/eml.Pictures in the text": { "hash": "sha256-c741ca3d02e5", "other": "Obrazki w treści" }, "Group.format/html.Pictures in the page": { "hash": "sha256-af608b8ed33a", "other": "Obrazki w stronie" }, + "Group.format/otlp.Log records": { "hash": "sha256-1eb4f5f3f4e1", "other": "Rekordy logów" }, + "Group.format/otlp.Metrics": { "hash": "sha256-a58da793c725", "other": "Metryki" }, + "Group.format/otlp.Pitfalls": { "hash": "sha256-24f33462a118", "other": "Pułapki" }, + "Group.format/otlp.Spans": { "hash": "sha256-b3393bfb2a55", "other": "Spany" }, "Group.format/pdf.Document properties": { "hash": "sha256-7115a778ab47", "other": "Właściwości dokumentu" }, "Group.format/rtf.Pictures in the document": { "hash": "sha256-1652b4354ef8", "other": "Obrazki w dokumencie" }, "Input.archive-scan.archive": { "hash": "sha256-4ad898a798a7", "other": "Archiwum do sprawdzenia: ZIP (DOCX, XLSX i JAR to w środku ZIP), TAR, TAR.GZ albo gzip. Jest tylko czytane." }, @@ -490,13 +530,17 @@ "Label.duration": { "hash": "sha256-4fc52a3c4c55", "other": "Czas trwania" }, "Label.elements": { "hash": "sha256-e1ff7a906103", "other": "Elementy" }, "Label.embed": { "hash": "sha256-7512561d2f68", "other": "Obraz w środku" }, + "Label.empty_envelope": { "hash": "sha256-c06d29fdbae1", "other": "Pusta koperta" }, "Label.encoding": { "hash": "sha256-4d25a47625ce", "other": "Kodowanie" }, "Label.encryption": { "hash": "sha256-82f4e719a19c", "other": "Szyfrowanie" }, + "Label.end_before_start": { "hash": "sha256-1ce5eed0b42b", "other": "Koniec przed początkiem" }, "Label.entries": { "hash": "sha256-7cb76b4af12a", "other": "Liczba plików" }, "Label.entry_format": { "hash": "sha256-63d61a75badf", "other": "Format plików w środku" }, "Label.entry_mode": { "hash": "sha256-4a17440263ca", "other": "Uprawnienia plików" }, "Label.entry_owner": { "hash": "sha256-d7bf94c5f409", "other": "Właściciel plików" }, "Label.entry_size": { "hash": "sha256-1190635edb6f", "other": "Rozmiar plików w środku" }, + "Label.enum_encoding": { "hash": "sha256-22601c0211e1", "other": "Zapis enumów" }, + "Label.exemplars": { "hash": "sha256-92f6b3f1e81e", "other": "Exemplary" }, "Label.expected": { "hash": "sha256-ca99b7f1b14e", "other": "Oczekiwana suma" }, "Label.far-over": { "hash": "sha256-874367c30d4e", "other": "Daleko ponad limit" }, "Label.file": { "hash": "sha256-50009ce1da4d", "other": "Plik" }, @@ -512,13 +556,17 @@ "Label.headers": { "hash": "sha256-194e9fe656a1", "other": "Nagłówki" }, "Label.height": { "hash": "sha256-bfe2eca40179", "other": "Wysokość" }, "Label.holes": { "hash": "sha256-158a3b71a54c", "other": "Dziury" }, + "Label.id_encoding": { "hash": "sha256-fd8f6b3477c6", "other": "Zapis identyfikatorów" }, "Label.ids": { "hash": "sha256-d9aa9d4aca97", "other": "Identyfikatory" }, "Label.inline": { "hash": "sha256-1e6245009d67", "other": "Liczba obrazków" }, "Label.inline_format": { "hash": "sha256-d2a00a20bba2", "other": "Format obrazków" }, "Label.inline_size": { "hash": "sha256-28a0f4295d32", "other": "Rozmiar obrazków" }, + "Label.interval": { "hash": "sha256-6f45b0005e3b", "other": "Odstęp zbiorów" }, "Label.ip_version": { "hash": "sha256-29016036fada", "other": "Wersja IP" }, + "Label.key_case": { "hash": "sha256-35a6d2a884c1", "other": "Zapis kluczy" }, "Label.keyframe_interval": { "hash": "sha256-e2ebcac3d589", "other": "Odstęp klatek kluczowych" }, "Label.keywords": { "hash": "sha256-8bfdb93cafaa", "other": "Słowa kluczowe" }, + "Label.large_numbers": { "hash": "sha256-428384dda8c4", "other": "Duże liczby" }, "Label.layout": { "hash": "sha256-a511909161e1", "other": "Układ" }, "Label.level_mix": { "hash": "sha256-cc4f6ea7c7fc", "other": "Poziomy ważności" }, "Label.limit": { "hash": "sha256-674b0ed54bf7", "other": "Limit" }, @@ -527,9 +575,11 @@ "Label.links": { "hash": "sha256-9024c197d849", "other": "Odnośniki" }, "Label.message_depth": { "hash": "sha256-8a18b09b1f37", "other": "Głębokość wiadomości" }, "Label.methods": { "hash": "sha256-8696622f3441", "other": "Metody" }, + "Label.metric_kind": { "hash": "sha256-c02cc2ddbac5", "other": "Rodzaj metryk" }, "Label.missing": { "hash": "sha256-6be36ca49ee8", "other": "Brakuje" }, "Label.modified": { "hash": "sha256-e8ce5dcaf408", "other": "Zmodyfikowano" }, "Label.nesting": { "hash": "sha256-f342629fc0e1", "other": "Zagnieżdżenie" }, + "Label.non_finite": { "hash": "sha256-7bd147326f22", "other": "NaN i Infinity" }, "Label.orientation": { "hash": "sha256-e3d11242200f", "other": "Orientacja" }, "Label.page_size": { "hash": "sha256-bd69e66e00ef", "other": "Rozmiar strony" }, "Label.pages": { "hash": "sha256-9046da16aea9", "other": "Strony" }, @@ -555,7 +605,9 @@ "Label.structure": { "hash": "sha256-7203c2b3dad3", "other": "Struktura" }, "Label.styles": { "hash": "sha256-3626e7fdea1c", "other": "Style" }, "Label.subject": { "hash": "sha256-68971283841a", "other": "Temat" }, + "Label.temporality": { "hash": "sha256-ea2c5cea729a", "other": "Temporalność" }, "Label.text_encoding": { "hash": "sha256-f6726ca0b9fe", "other": "Kodowanie tekstu" }, + "Label.time_order": { "hash": "sha256-88f72f946cf6", "other": "Kolejność czasu" }, "Label.timestamps": { "hash": "sha256-755f099dbf49", "other": "Znaczniki czasu" }, "Label.title": { "hash": "sha256-7e8cd2056da7", "other": "Tytuł" }, "Label.trace_context": { "hash": "sha256-07b45d3adabb", "other": "Kontekst śladu" }, @@ -653,5 +705,6 @@ "Unit.pixels": { "hash": "sha256-6ec9c2b0eb14", "other": "pikseli" }, "Unit.points": { "hash": "sha256-c54a061dfcc1", "other": "punktów" }, "Unit.rows": { "hash": "sha256-bc51e9e65d79", "other": "wierszy" }, + "Unit.seconds": { "hash": "sha256-59f006d63bd0", "other": "sekund" }, "Unit.slides": { "hash": "sha256-fd2c8f1dc03c", "other": "slajdów" } } diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index 335ff10a..b950ac2e 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -2466,11 +2466,26 @@ "hash": "sha256-8bd4f63f9cb2", "other": "a file holds whole records, and the longest one these settings can write needs that much" }, + "otlp.AFileThisLargeAddsUpMore": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Metric}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-f2957a096ca2", + "other": "a file of this size would add up more time in one series of {{.Metric}} than can be written to the microsecond. Choose a smaller file, or temporality delta where the kind has it" + }, + "otlp.AFileThisLargeCollectsAfter": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Interval}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-a57bf3d8c73f", + "other": "a file of this size collected every {{.Interval}} s would date its last collections after the year 2262. Choose an earlier start, a longer interval or a smaller file" + }, "otlp.AFileThisLargeEndsAfter": { "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Rate}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-511e89a36bd2", "other": "a file of this size at a rate of {{.Rate}} would date its last records after the year 2262. Choose an earlier start, a higher rate or a smaller file" }, + "otlp.ALineOfMetricsIsOneCollection": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Signal}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-3a22a0f2e186", + "other": "a line of {{.Signal}} holds one collection, so there is no batch to choose" + }, "otlp.ChooseAStartInThoseYears": { "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run.", "hash": "sha256-c72b07e145c8", @@ -2491,6 +2506,11 @@ "hash": "sha256-bcb9bc6dcb7b", "other": "it shapes log records, and signal {{.Signal}} writes none" }, + "otlp.ItShapesMetricsAndSignal": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Signal}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-2d43e342e97e", + "other": "it shapes metrics, and signal {{.Signal}} writes none" + }, "otlp.ItShapesSpansAndSignal": { "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Signal}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-0d5289829281", @@ -2501,11 +2521,51 @@ "hash": "sha256-3cebaa0f99ae", "other": "it takes a moment in UTC written as 2026-08-01T12:00:00Z" }, + "otlp.MetricsComeEveryInterval": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Signal}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-d4b5e492f847", + "other": "it sets how often a trace or a log record begins, and {{.Signal}} collects once every interval" + }, "otlp.OnlyOneLayoutHasBatches": { "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Lines}}, {{.Layout}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-b25305f5d3ab", "other": "only {{.Lines}} cuts the file into batches, and {{.Layout}} writes one request" }, + "otlp.OnlyOneLayoutHasLinesToAdd": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Lines}}, {{.Layout}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-13c1d68296f9", + "other": "only {{.Lines}} has a line to spare for an empty batch, and {{.Layout}} writes one request" + }, + "otlp.OnlyRequestDurationsHaveExemplars": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Kind}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-3805f6437863", + "other": "only the durations of requests, a histogram, name the traces they were measured in, and {{.Kind}} writes none" + }, + "otlp.OnlySumsAndHistogramsAddUp": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Kind}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-e975019ba3d5", + "other": "only a sum or a histogram adds up over time, and {{.Kind}} writes neither" + }, + "otlp.OnlyTheUtilizationGoesNaN": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Kind}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-704c5a52cd74", + "other": "only jvm.cpu.recent_utilization, a gauge, reports a value that is not a number, and {{.Kind}} writes no gauge" + }, + "otlp.TheseLogRecordsNameNoTrace": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Off}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-0220eeb2fa28", + "other": "it spells the ids of traces and spans, and {{.Off}} writes none" + }, + "otlp.TheseMetricsHaveNoEnum": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Kind}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-1991963263ab", + "other": "the one enum of metrics is the temporality of a sum or a histogram, and {{.Kind}} writes neither" + }, + "otlp.TheseMetricsNameNoTrace": { + "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.On}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-866b74c6b8df", + "other": "it spells the ids of traces and spans, and metrics name them only with {{.On}}" + }, "otlp.WriteTheMomentThatWay": { "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run.", "hash": "sha256-b21a2d65cac8", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index e35ffeed..08c8c6a6 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -491,14 +491,26 @@ "manifest.ValueTooLarge": { "hash": "sha256-65f9e8304926", "other": "{{.Path}} zawiera pojedynczą wartość dłuższą niż {{.Limit}} B, zaczynającą się mniej więcej {{.At}} B od początku, a to narzędzie czyta z manifestu naraz najwyżej tyle. Wpis manifestu zapisanego przez to narzędzie zajmuje kilobajt lub dwa, więc sprawdź, czy to manifest zapisany przez przebieg" }, "mp4.MinimumReasonPictures": { "hash": "sha256-2d553380ec9d", "other": "film o długości {{.Duration}} przy {{.FPS}} klatkach na sekundę z klatką kluczową co {{.Interval}} i nowym obrazem {{.Width}}x{{.Height}} co {{.Change}} zajmuje {{.Bytes}} B, a plik zawsze niesie pudełko free, które kosztuje {{.Free}} B nawet puste" }, "otlp.AFileHoldsWholeRecords": { "hash": "sha256-8bd4f63f9cb2", "other": "plik składa się z całych rekordów, a najdłuższy, jaki te ustawienia mogą zapisać, potrzebuje tyle" }, + "otlp.AFileThisLargeAddsUpMore": { "hash": "sha256-f2957a096ca2", "other": "plik tej wielkości dodałby w jednej serii {{.Metric}} więcej czasu, niż da się zapisać z dokładnością do mikrosekundy. Wybierz mniejszy plik albo temporalność przyrostowo, gdzie dany rodzaj ją ma" }, + "otlp.AFileThisLargeCollectsAfter": { "hash": "sha256-a57bf3d8c73f", "other": "plik tej wielkości zbierany co {{.Interval}} s datowałby swoje ostatnie zbiory po roku 2262. Wybierz wcześniejszy start, dłuższy odstęp albo mniejszy plik" }, "otlp.AFileThisLargeEndsAfter": { "hash": "sha256-511e89a36bd2", "other": "plik tej wielkości przy tempie {{.Rate}} datowałby ostatnie rekordy po roku 2262. Wybierz wcześniejszy początek, wyższe tempo albo mniejszy plik" }, + "otlp.ALineOfMetricsIsOneCollection": { "hash": "sha256-3a22a0f2e186", "other": "linia przy {{.Signal}} to jeden zbiór, więc nie ma paczki do wybrania" }, "otlp.ChooseAStartInThoseYears": { "hash": "sha256-c72b07e145c8", "other": "Wybierz początek w tych latach, na przykład 2026-08-01T12:00:00Z." }, "otlp.DropOneOfTheTwo": { "hash": "sha256-9d99490328fe", "other": "{{.Why}}. Usuń jedno z dwóch albo zmień drugie" }, "otlp.ItHasToLieInTheYears": { "hash": "sha256-2b01e1cc25a8", "other": "musi leżeć w latach od {{.First}} do {{.Last}}" }, "otlp.ItShapesLogRecordsAndSignal": { "hash": "sha256-bcb9bc6dcb7b", "other": "kształtuje rekordy logów, a sygnał {{.Signal}} nie zapisuje żadnego" }, + "otlp.ItShapesMetricsAndSignal": { "hash": "sha256-2d43e342e97e", "other": "kształtuje metryki, a sygnał {{.Signal}} ich nie pisze" }, "otlp.ItShapesSpansAndSignal": { "hash": "sha256-0d5289829281", "other": "kształtuje spany, a sygnał {{.Signal}} nie zapisuje żadnego" }, "otlp.ItTakesAMomentInUTC": { "hash": "sha256-3cebaa0f99ae", "other": "przyjmuje chwilę w UTC zapisaną jako 2026-08-01T12:00:00Z" }, + "otlp.MetricsComeEveryInterval": { "hash": "sha256-d4b5e492f847", "other": "ustawia, jak często zaczyna się ślad albo rekord logu, a przy {{.Signal}} zbiór przychodzi raz na odstęp" }, "otlp.OnlyOneLayoutHasBatches": { "hash": "sha256-b25305f5d3ab", "other": "tylko {{.Lines}} dzieli plik na paczki, a {{.Layout}} zapisuje jedno żądanie" }, + "otlp.OnlyOneLayoutHasLinesToAdd": { "hash": "sha256-13c1d68296f9", "other": "linię do oddania na pustą paczkę ma tylko {{.Lines}}, a {{.Layout}} pisze jedno żądanie" }, + "otlp.OnlyRequestDurationsHaveExemplars": { "hash": "sha256-3805f6437863", "other": "tylko czasy trwania żądań, histogram, podają ślady, w których je zmierzono, a {{.Kind}} ich nie pisze" }, + "otlp.OnlySumsAndHistogramsAddUp": { "hash": "sha256-e975019ba3d5", "other": "w czasie sumuje się tylko suma albo histogram, a {{.Kind}} nie pisze żadnego z nich" }, + "otlp.OnlyTheUtilizationGoesNaN": { "hash": "sha256-704c5a52cd74", "other": "wartość, która nie jest liczbą, zgłasza tylko jvm.cpu.recent_utilization, wskaźnik, a {{.Kind}} nie pisze wskaźnika" }, + "otlp.TheseLogRecordsNameNoTrace": { "hash": "sha256-0220eeb2fa28", "other": "określa zapis identyfikatorów śladów i spanów, a {{.Off}} żadnego nie pisze" }, + "otlp.TheseMetricsHaveNoEnum": { "hash": "sha256-1991963263ab", "other": "jedynym enumem metryk jest temporalność sumy albo histogramu, a {{.Kind}} nie pisze żadnego z nich" }, + "otlp.TheseMetricsNameNoTrace": { "hash": "sha256-866b74c6b8df", "other": "określa zapis identyfikatorów śladów i spanów, a metryki podają je tylko przy {{.On}}" }, "otlp.WriteTheMomentThatWay": { "hash": "sha256-b21a2d65cac8", "other": "Zapisz datę, literę T, czas co do sekundy i literę Z, bez ułamka sekundy." }, "pdf.Carrying": { "hash": "sha256-83e9af0cb805", "other": " z {{.What}}" }, "pdf.ChooseOneSize": { "hash": "sha256-61da6826cc02", "other": "wybierz jeden rozmiar, na przykład a4" }, diff --git a/internal/oracle/otelscripts.go b/internal/oracle/otelscripts.go index d2a45d82..6eddce9d 100644 --- a/internal/oracle/otelscripts.go +++ b/internal/oracle/otelscripts.go @@ -1,5 +1,7 @@ package oracle +import "encoding/json" + // otelcol is the OpenTelemetry Collector: its file receiver reads the file and // its file exporter writes back what it understood. // @@ -24,6 +26,21 @@ var otelcol = Checker{ accept: expectOK("the OpenTelemetry Collector"), } +// OTLPPitfalls asks the Collector about a file written with pitfalls, told +// which ones: the same script, which then spells every line the way the +// specification asks before it compares, and expects the lines the Collector +// was measured to drop to be missing. Byte for byte would be the wrong +// question here - the Collector writes its own spelling of what it read. +func OTLPPitfalls(path string, pitfalls map[string]string) Result { + told, err := json.Marshal(pitfalls) + if err != nil { + return Result{Available: true, Tool: otelcol.Name, Err: err} + } + c := otelcol + c.args = func(p string) []string { return []string{"-c", otelcolScript, p, string(told)} } + return c.Check(path) +} + const otelcolScript = ` import json, os, shutil, subprocess, sys, tempfile, time @@ -34,7 +51,53 @@ import json, os, shutil, subprocess, sys, tempfile, time STARTED = time.time() BUDGET = 40 LINE_CEILING = 1 << 20 -SIGNALS = {b'{"resourceSpans"': "traces", b'{"resourceLogs"': "logs", b'{"resourceMetrics"': "metrics"} +SIGNALS = {b'{"resourceSpans"': "traces", b'{"resourceLogs"': "logs", b'{"resourceMetrics"': "metrics", + b'{"resource_spans"': "traces", b'{"resource_logs"': "logs", b'{"resource_metrics"': "metrics"} +BOM = b"\xef\xbb\xbf" + +# The pitfalls the file was written with, when the guard says. Without them +# the question is the strong one below: the same bytes back. With them the +# Collector writes its own spelling of what it read, so each line is first +# spelled the way the specification asks and then compared, and a line the +# Collector was measured to drop is expected to be missing +# (docs/OTLP-2026-11-10.md section 13). +told = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {} +ENUMS = {"SPAN_KIND_INTERNAL": 1, "SPAN_KIND_SERVER": 2, "STATUS_CODE_ERROR": 2, + "SEVERITY_NUMBER_DEBUG": 5, "SEVERITY_NUMBER_INFO": 9, "SEVERITY_NUMBER_WARN": 13, "SEVERITY_NUMBER_ERROR": 17, + "AGGREGATION_TEMPORALITY_DELTA": 1, "AGGREGATION_TEMPORALITY_CUMULATIVE": 2} +ENUM_KEYS = {"kind", "code", "severityNumber", "aggregationTemporality"} +WIDE_KEYS = {"startTimeUnixNano", "endTimeUnixNano", "timeUnixNano", "observedTimeUnixNano", "intValue", "asInt", + "count", "bucketCounts", "zeroCount"} +ID_KEYS = {"traceId", "spanId", "parentSpanId"} +SENTINELS = { + "traces": '{"resourceSpans":[{"resource":{},"scopeSpans":[{"scope":{"name":"tfg.sentinel"},"spans":[{"traceId":"0102030405060708090a0b0c0d0e0f10",' + '"spanId":"0102030405060708","name":"sentinel","kind":1,"startTimeUnixNano":"1","endTimeUnixNano":"2","status":{}}]}]}]}', + "logs": '{"resourceLogs":[{"resource":{},"scopeLogs":[{"scope":{"name":"tfg.sentinel"},"logRecords":[{"timeUnixNano":"1","body":{"stringValue":"sentinel"}}]}]}]}', + "metrics": '{"resourceMetrics":[{"resource":{},"scopeMetrics":[{"scope":{"name":"tfg.sentinel"},"metrics":[{"name":"sentinel",' + '"gauge":{"dataPoints":[{"timeUnixNano":"1","asInt":"1"}]}}]}]}]}', +} + +def camel(key): + head, *rest = key.split("_") + return head + "".join(p[:1].upper() + p[1:] for p in rest) + +def canonical(value, key=None): + # What the specification spells: keys in lower camel case, an enum as its + # number, a number of 64 bits as text, ids as hex in lower case. + if isinstance(value, dict): + return {camel(k): canonical(v, camel(k)) for k, v in value.items()} + if isinstance(value, list): + return [canonical(v, key) for v in value] + if key in ENUM_KEYS and isinstance(value, str): + return ENUMS.get(value, value) + if key in WIDE_KEYS and type(value) is int: + return str(value) + if key in ID_KEYS and isinstance(value, str): + return value.lower() + return value + +def spelled(line): + return json.dumps(canonical(json.loads(line)), separators=(",", ":"), ensure_ascii=False).encode("utf-8") named = os.environ.get("TFG_OTELCOL", "") collector = named or shutil.which("otelcol-contrib") @@ -72,6 +135,12 @@ def is_object(line): with open(sys.argv[1], "rb") as f: data = f.read() +bom = told.get("bom") == "true" +if bom: + if not data.startswith(BOM): + print("FAIL bom=true and the file does not open with a byte order mark") + sys.exit(1) + data = data[len(BOM):] pieces = data.split(b"\n") by_line = data.endswith(b"\n") and all(is_object(p) for p in pieces[:-1]) if by_line: @@ -94,11 +163,36 @@ if signal is None: print("FAIL the file does not open with resourceSpans, resourceLogs or resourceMetrics") sys.exit(1) +# What the file receiver is handed, and what it has to write back. +fed, expected, dropped = wanted, wanted, [] +if told: + fed = [BOM + wanted[0]] + wanted[1:] if bom else wanted + expected = [] + for number, w in enumerate(wanted, start=1): + doc = canonical(json.loads(w)) + why = None + if not next(iter(doc.values())): + why = "an empty envelope" + elif told.get("id_encoding") == "base64": + why = "ids in base64" + elif bom and number == 1: + why = "the line behind the byte order mark" + if why: + dropped.append("%d (%s)" % (number, why)) + else: + expected.append(spelled(w)) + work = tempfile.mkdtemp(prefix="tfg-otelcol-") try: os.mkdir(os.path.join(work, "in")) with open(os.path.join(work, "in", "data.jsonl"), "wb") as f: - f.write(b"\n".join(wanted) + b"\n") + f.write(b"\n".join(fed) + b"\n") + if told: + # A batch the Collector reads whatever the file holds: once it is back, + # the file beside it has been read too, so a file that has to come + # back empty does not wait for the whole budget. + with open(os.path.join(work, "in", "zz-sentinel.jsonl"), "wb") as f: + f.write(SENTINELS[signal].encode("utf-8") + b"\n") out = os.path.join(work, "out.jsonl") longest = max(len(w) for w in wanted) lines = ["receivers:", " otlp_json_file:", " include: ['%s']" % (work.replace(os.sep, "/") + "/in/*.jsonl"), @@ -124,10 +218,18 @@ try: got, deadline = [], STARTED + BUDGET try: + seen, still = 0, time.time() while time.time() < deadline and process.poll() is None: time.sleep(0.2) got = written() - if len(got) >= len(wanted): + if told: + # Done once the sentinel is back and nothing more has come for + # a second and a half. + if len(got) != seen: + seen, still = len(got), time.time() + elif any(b"tfg.sentinel" in g for g in got) and time.time() - still > 1.5: + break + elif len(got) >= len(wanted): # One more look, so a batch the Collector wrote twice shows. time.sleep(0.6) got = written() @@ -145,6 +247,24 @@ try: tail = f.read()[-600:].decode("utf-8", "replace") print("FAIL the Collector did not run: %s" % tail.strip()) sys.exit(1) + if told: + if not any(b"tfg.sentinel" in g for g in got): + print("FAIL the Collector wrote nothing back in %d s, not even the batch every reader takes" % BUDGET) + sys.exit(1) + got = [g for g in got if b"tfg.sentinel" not in g] + if len(got) != len(expected): + print("FAIL %d batches came back and %d were expected, with batches %s dropped as measured" % ( + len(got), len(expected), ", ".join(dropped) or "none")) + sys.exit(1) + for number, (a, b) in enumerate(zip(expected, got), start=1): + if a != b: + at = next((i for i in range(min(len(a), len(b))) if a[i] != b[i]), min(len(a), len(b))) + print("FAIL batch %d came back different at byte %d once spelled the way the specification asks: ...%s against ...%s" % ( + number, at, a[max(0, at - 40):at + 40].decode("utf-8", "replace"), b[max(0, at - 40):at + 40].decode("utf-8", "replace"))) + sys.exit(1) + print("OK %d of %d batches of %s came back in the spelling of the specification, dropped as measured: %s" % ( + len(got), len(wanted), signal, ", ".join(dropped) or "none")) + sys.exit(0) if len(got) != len(wanted): print("FAIL %d of %d batches came back - the Collector dropped the rest without a word, which is what it does " "with a line longer than %d bytes and with one it cannot read" % (len(got), len(wanted), LINE_CEILING)) diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index 12365328..634f59e8 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -4167,7 +4167,118 @@ def rtf_every(text, settings): OTLP_LINE_CEILING = 1 << 20 OTLP_SCHEMA = "https://opentelemetry.io/schemas/1.45.0" OTLP_PADDING = "tfg.padding" -OTLP_NAMES = {"traces": ("resourceSpans", "scopeSpans", "spans"), "logs": ("resourceLogs", "scopeLogs", "logRecords")} +OTLP_NAMES = {"traces": ("resourceSpans", "scopeSpans", "spans"), "logs": ("resourceLogs", "scopeLogs", "logRecords"), + "metrics": ("resourceMetrics", "scopeMetrics", "metrics")} +# The instruments of a file of metrics, written out here a second time rather +# than read from the generator: the member that holds the points, the unit, +# what a point carries, how many series, the attribute keys of a series, and +# whether a sum only grows or stays cumulative under delta (UpDownCounter). +OTLP_HTTP_KEYS = (["http.request.method", "url.scheme", "http.response.status_code", "http.route"], + ["http.request.method", "url.scheme", "error.type", "http.response.status_code", "http.route"]) +OTLP_INSTRUMENTS = { + "jvm.cpu.recent_utilization": {"member": "gauge", "unit": "1", "value": "double", "series": 1, "keys": ([],)}, + "shop.orders.pending": {"member": "gauge", "unit": "{order}", "value": "int", "series": 1, "keys": ([],)}, + "jvm.cpu.time": {"member": "sum", "unit": "s", "value": "double", "series": 1, "keys": ([],), "monotonic": True}, + "jvm.class.loaded": {"member": "sum", "unit": "{class}", "value": "int", "series": 1, "keys": ([],), "monotonic": True}, + "jvm.memory.used": {"member": "sum", "unit": "By", "value": "int", "series": 3, "keys": (["jvm.memory.pool.name", "jvm.memory.type"],), + "updown": True}, + "http.server.request.duration": {"member": "histogram", "unit": "s", "value": "histogram", "series": 16, "keys": OTLP_HTTP_KEYS}, + "jvm.gc.duration": {"member": "exponentialHistogram", "unit": "s", "value": "exponential", "series": 2, + "keys": (["jvm.gc.action", "jvm.gc.name"],)}, + "http.server.requests": {"member": "summary", "unit": "s", "value": "summary", "series": 16, + "keys": (["exception", "method", "outcome", "status", "uri"],)}, +} +OTLP_METRIC_KINDS = { + "gauge": ["jvm.cpu.recent_utilization", "shop.orders.pending"], + "sum": ["jvm.cpu.time", "jvm.class.loaded", "jvm.memory.used"], + "histogram": ["http.server.request.duration"], + "exponential-histogram": ["jvm.gc.duration"], + "summary": ["http.server.requests"], +} +OTLP_METRIC_KINDS["mixed"] = [n for k in ("gauge", "sum", "histogram", "exponential-histogram", "summary") for n in OTLP_METRIC_KINDS[k]] +# The bucket boundaries the HTTP conventions name for request durations. +OTLP_REQUEST_BOUNDS = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10] +OTLP_QUANTILES = [0.5, 0.95, 0.99] +OTLP_MOST_MEASUREMENTS = 3 +# The pitfalls. The enums by the names their .proto files give them, the +# fields that hold a number of 64 bits, and the fields that hold an id. +OTLP_ENUM_NAMES = {"kind": {1: "SPAN_KIND_INTERNAL", 2: "SPAN_KIND_SERVER"}, "code": {2: "STATUS_CODE_ERROR"}, + "severityNumber": {5: "SEVERITY_NUMBER_DEBUG", 9: "SEVERITY_NUMBER_INFO", 13: "SEVERITY_NUMBER_WARN", + 17: "SEVERITY_NUMBER_ERROR"}, + "aggregationTemporality": {1: "AGGREGATION_TEMPORALITY_DELTA", 2: "AGGREGATION_TEMPORALITY_CUMULATIVE"}} +OTLP_WIDE = {"startTimeUnixNano", "endTimeUnixNano", "timeUnixNano", "observedTimeUnixNano", "intValue", "asInt", + "count", "bucketCounts", "zeroCount"} +OTLP_ID_KEYS = {"traceId", "spanId", "parentSpanId"} +OTLP_PITFALLS = {"id_encoding": ("hex", "upper-hex", "base64"), "enum_encoding": ("number", "name"), "key_case": ("camel", "snake"), + "large_numbers": ("text", "number"), "bom": ("false", "true"), "time_order": ("in-order", "out-of-order"), + "end_before_start": ("false", "true"), "empty_envelope": ("false", "true"), "body": ("text", "any-value"), + "non_finite": ("false", "true")} +# 2^53 + 1, the whole number a log body of any-value holds. +OTLP_BEYOND_DOUBLES = "9007199254740993" + + +def otlp_camel(key): + head, *rest = key.split("_") + return head + "".join(p[:1].upper() + p[1:] for p in rest) + + +def otlp_snake(key): + return "".join("_" + c.lower() if c.isupper() else c for c in key) + + +def otlp_respell(value, settings, canonical, key=None): + """A batch spelled the way the specification asks, out of one spelled with + the pitfalls of settings, or the other way round. Each way changes only + what the settings name, so a file that ignored one of them does not come + back to itself when spelled again.""" + import base64 + import binascii + + if isinstance(value, dict): + out = {} + for k, v in value.items(): + plain = otlp_camel(k) if canonical and settings["key_case"] == "snake" else k + name = otlp_snake(k) if not canonical and settings["key_case"] == "snake" else plain + out[name] = otlp_respell(v, settings, canonical, plain) + return out + if isinstance(value, list): + return [otlp_respell(v, settings, canonical, key) for v in value] + if key in OTLP_ENUM_NAMES and settings["enum_encoding"] == "name": + names = OTLP_ENUM_NAMES[key] + if canonical: + return {n: v for v, n in names.items()}.get(value, value) if isinstance(value, str) else value + return names.get(value, value) if type(value) is int else value + if key in OTLP_WIDE and settings["large_numbers"] == "number": + if canonical: + return str(value) if type(value) is int else value + return int(value) if isinstance(value, str) and value.isdigit() else value + if key in OTLP_ID_KEYS and settings["id_encoding"] != "hex" and isinstance(value, str): + if settings["id_encoding"] == "upper-hex": + return value.lower() if canonical else value.upper() + try: + return base64.b64decode(value, validate=True).hex() if canonical else base64.b64encode(bytes.fromhex(value)).decode() + except (binascii.Error, ValueError): + return value + return value + + +def otlp_canonical(batches, settings): + """The batches spelled the way the specification asks, each checked to be + exactly what the pitfalls make of that spelling.""" + out = [] + for number, doc in enumerate(batches, start=1): + canon = otlp_respell(doc, settings, True) + if otlp_respell(canon, settings, False) != doc: + fail(f"batch {number} is not spelled the way id_encoding={settings['id_encoding']}, enum_encoding={settings['enum_encoding']}, " + f"key_case={settings['key_case']} and large_numbers={settings['large_numbers']} spell it") + out.append(canon) + return out + + +def otlp_tick(n, settings): + """Where on the clock the nth trace, log record or collection is dated: + there, or with time_order out-of-order where its neighbour would be.""" + return n ^ 1 if settings["time_order"] == "out-of-order" else n OTLP_STATUSES = {"realistic": {200, 201, 204, 301, 302, 304, 400, 401, 403, 404, 409, 429, 500, 502, 503}, "success": {200, 201, 204, 206}, "client-errors": {400, 401, 403, 404, 409, 410, 422, 429}, @@ -4250,30 +4361,11 @@ def otlp_batches(text, settings): def otlp_records(batches, settings): """Every record of the file in order, out of batches that each name one resource and one scope and hold as many records as batch says.""" - top, mid, leaf = OTLP_NAMES[settings["signal"]] records, sizes = [], [] for number, doc in enumerate(batches, start=1): - if list(doc) != [top] or len(doc[top]) != 1: - fail(f"batch {number} is not one {top} entry: its members are {list(doc)}") - res = doc[top][0] - if list(res) != ["resource", mid, "schemaUrl"] or res["schemaUrl"] != OTLP_SCHEMA: - fail(f"batch {number}: the resource entry has the members {list(res)}, not a resource, {mid} and the schema of the conventions") - if list(res["resource"]) != ["attributes"] or [a["key"] for a in res["resource"]["attributes"]] != ["service.name", "service.version"]: - fail(f"batch {number}: the resource does not carry service.name and service.version and nothing else") - for a in res["resource"]["attributes"]: - if list(a) != ["key", "value"] or not otlp_text(f"the resource of batch {number}", a["value"]): - fail(f"batch {number}: the resource attribute {a['key']} is not one key with a text in it") - if len(res[mid]) != 1: - fail(f"batch {number} holds {len(res[mid])} scopes, not one") - scope = res[mid][0] - if list(scope) != ["scope", leaf, "schemaUrl"] or scope["schemaUrl"] != OTLP_SCHEMA or list(scope["scope"]) != ["name", "version"]: - fail(f"batch {number}: the scope entry has the members {list(scope)}, not a named scope, {leaf} and the schema of the conventions") - if not all(isinstance(v, str) and v for v in scope["scope"].values()): - fail(f"batch {number}: the scope does not carry its name and its version as text") - if not scope[leaf]: - fail(f"batch {number} holds no record, an envelope the specification asks senders not to write") - sizes.append(len(scope[leaf])) - records.extend(scope[leaf]) + held = otlp_scope(doc, number, settings) + sizes.append(len(held)) + records.extend(held) if settings["formatting"] == "record-per-line": size = settings["batch"] if any(n != size for n in sizes[:-1]) or sizes[-1] > size: @@ -4281,6 +4373,32 @@ def otlp_records(batches, settings): return records +def otlp_scope(doc, number, settings): + """The records of one batch, out of an envelope that names one resource + and one scope.""" + top, mid, leaf = OTLP_NAMES[settings["signal"]] + if list(doc) != [top] or len(doc[top]) != 1: + fail(f"batch {number} is not one {top} entry: its members are {list(doc)}") + res = doc[top][0] + if list(res) != ["resource", mid, "schemaUrl"] or res["schemaUrl"] != OTLP_SCHEMA: + fail(f"batch {number}: the resource entry has the members {list(res)}, not a resource, {mid} and the schema of the conventions") + if list(res["resource"]) != ["attributes"] or [a["key"] for a in res["resource"]["attributes"]] != ["service.name", "service.version"]: + fail(f"batch {number}: the resource does not carry service.name and service.version and nothing else") + for a in res["resource"]["attributes"]: + if list(a) != ["key", "value"] or not otlp_text(f"the resource of batch {number}", a["value"]): + fail(f"batch {number}: the resource attribute {a['key']} is not one key with a text in it") + if len(res[mid]) != 1: + fail(f"batch {number} holds {len(res[mid])} scopes, not one") + scope = res[mid][0] + if list(scope) != ["scope", leaf, "schemaUrl"] or scope["schemaUrl"] != OTLP_SCHEMA or list(scope["scope"]) != ["name", "version"]: + fail(f"batch {number}: the scope entry has the members {list(scope)}, not a named scope, {leaf} and the schema of the conventions") + if not all(isinstance(v, str) and v for v in scope["scope"].values()): + fail(f"batch {number}: the scope does not carry its name and its version as text") + if not scope[leaf]: + fail(f"batch {number} holds no record, an envelope the specification asks senders not to write") + return scope[leaf] + + def otlp_attributes(what, record, last): """The attributes of a record by key, in the order written. The padding attribute is taken out: it belongs on the last record of the file and on @@ -4336,12 +4454,16 @@ def otlp_spans(spans, settings, start, step): otlp_id(f"the trace id of {what}", sp["traceId"], 32) otlp_id(f"the id of {what}", sp["spanId"], 16) begin, end = otlp_time(f"the start of {what}", sp["startTimeUnixNano"]), otlp_time(f"the end of {what}", sp["endTimeUnixNano"]) + if settings["end_before_start"] == "true": + if end >= begin: + fail(f"{what} ends at or after the moment it starts, and end_before_start=true dates every span the other way round") + begin, end = end, begin if end <= begin: fail(f"{what} ends at or before the moment it starts") attrs = otlp_attributes(what, sp, number == len(spans)) if level == 0: - if begin != start + traces * step: - fail(f"{what} opens trace {traces + 1} at {begin}, and start and rate put it at {start + traces * step}") + if begin != start + otlp_tick(traces, settings) * step: + fail(f"{what} opens trace {traces + 1} at {begin}, and start, rate and time_order put it at {start + otlp_tick(traces, settings) * step}") traces += 1 otlp_request(what, sp, attrs, settings) else: @@ -4388,15 +4510,15 @@ def otlp_log_records(entries, settings, start, step): if list(en) != want: fail(f"{what} has the members {list(en)}, not {want}") at = otlp_time(f"the time of {what}", en["timeUnixNano"]) - if at != start + (number - 1) * step or en["observedTimeUnixNano"] != en["timeUnixNano"]: - fail(f"{what} is dated {at}, and start and rate put it at {start + (number - 1) * step} with the same moment observed") + due = start + otlp_tick(number - 1, settings) * step + if at != due or en["observedTimeUnixNano"] != en["timeUnixNano"]: + fail(f"{what} is dated {at}, and start, rate and time_order put it at {due} with the same moment observed") level = en["severityText"] if level not in OTLP_LEVEL_MIXES[settings["level_mix"]]: fail(f"{what} has the severity {level!r}, which level_mix={settings['level_mix']} does not hold") if type(en["severityNumber"]) is not int or en["severityNumber"] != OTLP_LEVELS[level]: fail(f"{what} says {level} and carries the number {en['severityNumber']!r}, not {OTLP_LEVELS[level]}") - if not otlp_text(what, en["body"]): - fail(f"{what} says nothing") + otlp_body(what, en["body"], number - 1, settings) attrs = otlp_attributes(what, en, number == len(entries)) if list(attrs) != ["code.function.name", "code.line.number"]: fail(f"{what} carries the attributes {list(attrs)}, not the function and the line it was written at") @@ -4410,6 +4532,205 @@ def otlp_log_records(entries, settings, start, step): return f"{len(entries)} log records" +def otlp_body(what, body, n, settings): + """What a log record says: a sentence, or with body any-value the seven + kinds of AnyValue in turn, each holding what the record says.""" + import base64 + + kind = n % 7 if settings["body"] == "any-value" else 0 + shapes = ["stringValue", "boolValue", "intValue", "doubleValue", "arrayValue", "kvlistValue", "bytesValue"] + if list(body) != [shapes[kind]]: + fail(f"{what} has the body {list(body)}, and record {n + 1} with body={settings['body']} holds {shapes[kind]}") + v = body[shapes[kind]] + good = { + 0: lambda: isinstance(v, str) and v, + 1: lambda: v is True, + 2: lambda: v == OTLP_BEYOND_DOUBLES, + 3: lambda: type(v) in (int, float) and 0 < v < 1, + 4: lambda: [list(x) for x in v["values"]] == [["stringValue"], ["intValue"]] and v["values"][0]["stringValue"], + 5: lambda: [x["key"] for x in v["values"]] == ["message", "code.line.number"], + 6: lambda: base64.b64decode(v, validate=True).decode("utf-8"), + }[kind] + try: + held = good() + except (ValueError, TypeError, KeyError, UnicodeDecodeError): + held = False + if not held: + fail(f"{what} has a {shapes[kind]} body that does not hold what a record says") + + +def otlp_metric_points(batches, settings): + """Every point of the file in order, each with its metric and its place in + a collection: the instruments of metric_kind in their order, every series + of each, a metric opening at the first series of a collection and closing + at the last, and in record-per-line one collection to a line.""" + names = OTLP_METRIC_KINDS[settings["metric_kind"]] + slots = [(name, j) for name in names for j in range(OTLP_INSTRUMENTS[name]["series"])] + points, sizes = [], [] + for number, doc in enumerate(batches, start=1): + held = 0 + for m in otlp_scope(doc, number, settings): + inst = OTLP_INSTRUMENTS.get(m.get("name")) if isinstance(m, dict) else None + if inst is None: + fail(f"batch {number} holds a metric {m.get('name') if isinstance(m, dict) else m!r} this tool does not write") + if list(m) != ["name", "description", "unit", inst["member"]] or not m["description"] or m["unit"] != inst["unit"]: + fail(f"the metric {m['name']} has the members {list(m)} and the unit {m.get('unit')!r}, not a {inst['member']} in {inst['unit']}") + data = m[inst["member"]] + temporal = inst["member"] in ("sum", "histogram", "exponentialHistogram") + want = ["dataPoints"] + (["aggregationTemporality"] if temporal else []) + (["isMonotonic"] if inst.get("monotonic") else []) + if list(data) != want or (inst.get("monotonic") and data["isMonotonic"] is not True): + fail(f"the {inst['member']} of {m['name']} has the members {list(data)}, not {want}") + if temporal: + cumulative = settings["temporality"] == "cumulative" or inst.get("updown") + if type(data["aggregationTemporality"]) is not int or data["aggregationTemporality"] != (2 if cumulative else 1): + fail(f"{m['name']} states the temporality {data['aggregationTemporality']!r} and temporality={settings['temporality']} asks for {2 if cumulative else 1}") + if not data["dataPoints"]: + fail(f"{m['name']} in batch {number} holds no point") + for k, pt in enumerate(data["dataPoints"]): + name, series = slots[len(points) % len(slots)] + if name != m["name"] or (k == 0 and series != 0): + fail(f"point {len(points) + 1} is a point of {m['name']}, where the order of a collection puts series {series + 1} of {name}") + points.append((m["name"], series, len(points) // len(slots), pt)) + held += len(data["dataPoints"]) + if points[-1][1] != OTLP_INSTRUMENTS[m["name"]]["series"] - 1 and number != len(batches): + fail(f"{m['name']} in batch {number} closes before its last series") + sizes.append(held) + if settings["formatting"] == "record-per-line" and (any(n != len(slots) for n in sizes[:-1]) or sizes[-1] > len(slots)): + fail(f"the lines hold {sizes[:6]} points, and a line holds one collection of {len(slots)}") + return points + + +def otlp_double(what, value, low=0.0, high=1e16): + if type(value) not in (int, float) or not low <= value < high: + fail(f"{what} is {value!r}, not a number from {low} up to {high}") + return value + + +def otlp_count(what, value): + if not isinstance(value, str) or not value.isdigit() or (len(value) > 1 and value[0] == "0"): + fail(f"{what} is {value!r}, not a count written as text of digits") + return int(value) + + +def otlp_metrics(batches, settings, start, step): + """Points dated by their collection, every series carrying the same + attributes in every collection, and values that add up: a cumulative count + never falls, the buckets of a histogram hold its count, a delta holds what + one interval measured, and an exemplar sits in a bucket its collection + counted.""" + points = otlp_metric_points(batches, settings) + seen, before = {}, {} + for number, (name, series, collection, pt) in enumerate(points, start=1): + inst, what = OTLP_INSTRUMENTS[name], f"point {number} ({name})" + cumulative = settings["temporality"] == "cumulative" or inst.get("updown") or inst["member"] in ("gauge", "summary") + at = start + (otlp_tick(collection, settings) + 1) * step + if otlp_time(f"the time of {what}", pt.get("timeUnixNano")) != at: + fail(f"{what} is dated {pt.get('timeUnixNano')}, and collection {collection + 1} at interval={settings['interval']} is at {at}") + begun = otlp_time(f"the start of {what}", pt.get("startTimeUnixNano")) + if begun != (start if cumulative else at - step): + fail(f"{what} starts at {begun}, and a {'cumulative' if cumulative else 'delta'} point starts at {start if cumulative else at - step}") + attrs = otlp_attributes(what, pt, number == len(points)) if "attributes" in pt else {} + if "attributes" not in pt and number == len(points): + fail(f"{what} is the last point of the file and carries no {OTLP_PADDING} attribute") + if list(attrs) not in [list(k) for k in inst["keys"]]: + fail(f"{what} carries the attributes {list(attrs)}, not those of {name}") + key = (name, series) + if seen.setdefault(key, attrs) != attrs: + fail(f"{what} carries other attributes than series {series + 1} of {name} carried before") + rest = [k for k in pt if k not in ("attributes", "startTimeUnixNano", "timeUnixNano")] + value = inst["value"] + if value in ("double", "int"): + if rest != (["asDouble"] if value == "double" else ["asInt"]): + fail(f"{what} carries {rest}, not one {'asDouble' if value == 'double' else 'asInt'}") + if name == "jvm.cpu.recent_utilization" and settings["non_finite"] == "true" and collection % 4 in (1, 3): + want = "NaN" if collection % 4 == 1 else ("Infinity" if collection // 4 % 2 == 0 else "-Infinity") + if pt["asDouble"] != want: + fail(f"{what} reports {pt['asDouble']!r} in collection {collection + 1}, where non_finite=true reports {want!r}") + continue + n = otlp_double(what, pt["asDouble"], 0.0001) if value == "double" else otlp_count(what, pt["asInt"]) + if name == "jvm.cpu.recent_utilization" and not n < 1: + fail(f"{what} says the process used {n} of one processor") + if inst.get("monotonic") and cumulative and n < before.get(key, 0): + fail(f"{what} is {n}, below the {before[key]} the counter had reached") + before[key] = n + continue + count = otlp_count(f"the count of {what}", pt.get("count")) + total = otlp_double(f"the sum of {what}", pt.get("sum"), 0.0001) + if count < 1 or (not cumulative and count > OTLP_MOST_MEASUREMENTS): + fail(f"{what} counts {count} measurements, and a {'cumulative' if cumulative else 'delta'} point here counts 1 to {OTLP_MOST_MEASUREMENTS if not cumulative else 'any'}") + if cumulative and count < before.get(key, (0,))[0]: + fail(f"{what} counts {count}, fewer than the {before[key][0]} the series had counted") + if value == "summary": + if rest != ["count", "sum", "quantileValues"]: + fail(f"{what} carries {rest}, not the count, the sum and the quantiles") + qs = pt["quantileValues"] + if [q.get("quantile") for q in qs] != OTLP_QUANTILES or any(list(q) != ["quantile", "value"] for q in qs): + fail(f"{what} reports the quantiles {[q.get('quantile') for q in qs]}, not {OTLP_QUANTILES}") + vals = [otlp_double(f"a quantile of {what}", q["value"], 0.001, 2.000001) for q in qs] + if vals != sorted(vals): + fail(f"{what} has quantile values {vals} that fall") + before[key] = (count,) + continue + least = otlp_double(f"the least of {what}", pt.get("min"), 0.001, 2.000001) + most = otlp_double(f"the most of {what}", pt.get("max"), least, 2.000001) + if not count * least - 1e-6 <= total <= count * most + 1e-6: + fail(f"{what} sums {total} over {count} measurements from {least} to {most}") + if value == "histogram": + want = ["count", "sum", "bucketCounts", "explicitBounds"] + (["exemplars"] if "exemplars" in pt else []) + ["min", "max"] + if rest != want or pt["explicitBounds"] != OTLP_REQUEST_BOUNDS: + fail(f"{what} carries {rest}, not {want} with the bounds the HTTP conventions name") + buckets = [otlp_count(f"a bucket of {what}", c) for c in pt["bucketCounts"]] + if len(buckets) != len(OTLP_REQUEST_BOUNDS) + 1: + fail(f"{what} has {len(buckets)} buckets for {len(OTLP_REQUEST_BOUNDS)} bounds") + otlp_exemplars(what, pt, settings, buckets, at, step, cumulative, before.get(key)) + else: + want = ["count", "sum", "scale", "positive", "negative", "min", "max"] + if rest != want or type(pt["scale"]) is not int or pt["scale"] != 3 or pt["negative"] != {}: + fail(f"{what} carries {rest} at scale {pt.get('scale')!r}, not {want} at scale 3 with nothing below nought") + positive = pt["positive"] + offset = positive.get("offset", 0) + if list(positive) not in (["offset", "bucketCounts"], ["bucketCounts"]) or type(offset) is not int or offset == 0 and "offset" in positive: + fail(f"{what} has the positive buckets {list(positive)}, not an offset left out at nought and the counts") + buckets = [otlp_count(f"a bucket of {what}", c) for c in positive["bucketCounts"]] + if not buckets or buckets[0] == 0 or buckets[-1] == 0 or offset < -80 or offset + len(buckets) > 8: + fail(f"{what} writes buckets {offset} to {offset + len(buckets) - 1}, which do not start and end with a count inside -80 to 7") + if not 2 ** ((offset) / 8) < least * (1 + 1e-9) or not most <= 2 ** ((offset + len(buckets)) / 8) * (1 + 1e-9): + fail(f"{what} measured from {least} to {most}, outside the buckets it writes") + if sum(buckets) != count: + fail(f"the buckets of {what} hold {sum(buckets)} measurements and its count says {count}") + if cumulative and value == "histogram" and key in before and any(a < b for a, b in zip(buckets, before[key][1])): + fail(f"a bucket of {what} holds fewer measurements than it held a collection before") + before[key] = (count, buckets) + return f"{len(points)} points in {points[-1][2] + 1} collections" + + +def otlp_exemplars(what, pt, settings, buckets, at, step, cumulative, earlier): + """When exemplars is on, one exemplar for every bucket the collection + measured in and none for any other, each dated inside its interval, in the + order of the buckets.""" + measured = buckets if not cumulative or earlier is None else [a - b for a, b in zip(buckets, earlier[1])] + if "exemplars" not in pt: + if settings["exemplars"]: + fail(f"{what} names no measurement, and exemplars=true asks it to") + return + if not settings["exemplars"]: + fail(f"{what} names measurements, and exemplars=false asks for none") + hit = [i for i, n in enumerate(measured) if n] + found = [] + for ex in pt["exemplars"]: + if list(ex) != ["timeUnixNano", "asDouble", "traceId", "spanId"]: + fail(f"an exemplar of {what} has the members {list(ex)}, not the time, the value, the trace and the span") + when = otlp_time(f"the time of an exemplar of {what}", ex["timeUnixNano"]) + if not at - step < when <= at: + fail(f"an exemplar of {what} is dated {when}, outside the interval its collection covers") + otlp_id(f"the trace of an exemplar of {what}", ex["traceId"], 32) + otlp_id(f"the span of an exemplar of {what}", ex["spanId"], 16) + v = otlp_double(f"the value of an exemplar of {what}", ex["asDouble"], 0.001, 2.000001) + found.append(next((i for i, b in enumerate(OTLP_REQUEST_BOUNDS) if v <= b), len(OTLP_REQUEST_BOUNDS))) + if found != hit: + fail(f"{what} names measurements in the buckets {found}, and its collection measured in {hit}") + + def check_otlp(data, settings=None): """OTLP in its JSON encoding, made the way it was ordered. @@ -4439,12 +4760,15 @@ def check_otlp(data, settings=None): "status_mix": given.get("status_mix", "realistic"), "level_mix": given.get("level_mix", "realistic"), "trace_context": given.get("trace_context", "true") == "true", + "metric_kind": given.get("metric_kind", "mixed"), + "temporality": given.get("temporality", "cumulative"), + "exemplars": given.get("exemplars", "false") == "true", } # The numbers are read against the ranges the format declares, so a word # or a nought handed to this check is a refusal that names the setting # rather than a traceback a guard would read as the checker falling over # (review of #230). - for name, default, most in (("batch", "512", 1000), ("rate", "1", 1000000), ("depth", "3", 100)): + for name, default, most in (("batch", "512", 1000), ("rate", "1", 1000000), ("depth", "3", 100), ("interval", "60", 86400)): told = given.get(name, default) if not told.isdigit() or not 1 <= int(told) <= most: fail(f"the otlp check was told {name}={told!r}, and {name} is a whole number from 1 to {most}") @@ -4453,8 +4777,17 @@ def check_otlp(data, settings=None): fail(f"the otlp check was told signal={settings['signal']!r}, which is not a kind of telemetry this tool writes") if settings["status_mix"] not in OTLP_STATUSES or settings["level_mix"] not in OTLP_LEVEL_MIXES: fail(f"the otlp check was told status_mix={settings['status_mix']!r} and level_mix={settings['level_mix']!r}, and one of them is not a mix this tool draws from") - if data.startswith(b"\xef\xbb\xbf"): - fail("the file starts with a byte order mark") + if settings["metric_kind"] not in OTLP_METRIC_KINDS or settings["temporality"] not in ("cumulative", "delta"): + fail(f"the otlp check was told metric_kind={settings['metric_kind']!r} and temporality={settings['temporality']!r}, and one of them is not one this tool writes") + for name, spellings in OTLP_PITFALLS.items(): + settings[name] = given.get(name, spellings[0]) + if settings[name] not in spellings: + fail(f"the otlp check was told {name}={settings[name]!r}, and {name} is one of {', '.join(spellings)}") + bom = data.startswith(b"\xef\xbb\xbf") + if bom != (settings["bom"] == "true"): + fail(f"the file {'starts' if bom else 'does not start'} with a byte order mark, and bom={settings['bom']}") + if bom: + data = data[3:] try: text = data.decode("utf-8") except UnicodeDecodeError as exc: @@ -4463,18 +4796,25 @@ def check_otlp(data, settings=None): start = calendar.timegm(time.strptime(settings["start"], "%Y-%m-%dT%H:%M:%SZ")) * 10**9 except ValueError: fail(f"the otlp check was told start={settings['start']!r}, which is not a moment written as 2026-08-01T12:00:00Z") - batches = otlp_batches(text, settings) - step = 10**9 // settings["rate"] + if settings["empty_envelope"] == "true": + top = OTLP_NAMES[settings["signal"]][0] + empty = '{"%s":[]}\n' % (otlp_snake(top) if settings["key_case"] == "snake" else top) + if settings["formatting"] != "record-per-line" or not text.startswith(empty): + fail(f"empty_envelope=true and the file does not open with the line {empty.strip()}") + text = text[len(empty):] + batches = otlp_canonical(otlp_batches(text, settings), settings) + step = 10**9 // settings["rate"] if settings["signal"] != "metrics" else settings["interval"] * 10**9 # The checks name the shapes this tool could get wrong. A shape nobody # thought of still has to come out as a refusal with a reason rather than # a Python traceback, because a crash also turns a guard red and proves # nothing (the lesson of check_geojson, review of #171). try: - records = otlp_records(batches, settings) - if settings["signal"] == "traces": - held = otlp_spans(records, settings, start, step) + if settings["signal"] == "metrics": + held = otlp_metrics(batches, settings, start, step) + elif settings["signal"] == "traces": + held = otlp_spans(otlp_records(batches, settings), settings, start, step) else: - held = otlp_log_records(records, settings, start, step) + held = otlp_log_records(otlp_records(batches, settings), settings, start, step) except (TypeError, KeyError, IndexError, AttributeError, ValueError) as exc: fail(f"a record is not shaped the way this tool writes one: {type(exc).__name__}: {exc}") ok(f"{held}, {len(batches)} batches, laid out {settings['formatting']}") diff --git a/web/content/ar/site.json b/web/content/ar/site.json index 65acd0bd..8cb286de 100644 --- a/web/content/ar/site.json +++ b/web/content/ar/site.json @@ -267,6 +267,7 @@ "holes": "ثقوب", "million cells": "مليون خلية", "entries per second": "إدخال في الثانية", + "seconds": "ثانية", "files": "ملفات", "sizes separated by commas": "أحجام مفصولة بفواصل", "format ids separated by commas": "معرّفات صيغ مفصولة بفواصل", diff --git a/web/content/cs/site.json b/web/content/cs/site.json index eb82692f..665befea 100644 --- a/web/content/cs/site.json +++ b/web/content/cs/site.json @@ -266,6 +266,7 @@ "holes": "děr", "million cells": "milionů buněk", "entries per second": "položek za sekundu", + "seconds": "sekund", "files": "souborů", "sizes separated by commas": "velikosti oddělené čárkami", "format ids separated by commas": "id formátů oddělená čárkami", diff --git a/web/content/de/site.json b/web/content/de/site.json index a5565d93..3974b5f0 100644 --- a/web/content/de/site.json +++ b/web/content/de/site.json @@ -266,6 +266,7 @@ "holes": "Löcher", "million cells": "Millionen Zellen", "entries per second": "Einträge pro Sekunde", + "seconds": "Sekunden", "files": "Dateien", "sizes separated by commas": "Größen, durch Kommas getrennt", "format ids separated by commas": "Format-IDs, durch Kommas getrennt", diff --git a/web/content/en/site.json b/web/content/en/site.json index 84768f49..e309df49 100644 --- a/web/content/en/site.json +++ b/web/content/en/site.json @@ -261,6 +261,7 @@ "holes": "holes", "million cells": "million cells", "entries per second": "entries per second", + "seconds": "seconds", "files": "files", "sizes separated by commas": "sizes separated by commas", "format ids separated by commas": "format ids separated by commas", diff --git a/web/content/es/site.json b/web/content/es/site.json index 91685873..89f68b56 100644 --- a/web/content/es/site.json +++ b/web/content/es/site.json @@ -266,6 +266,7 @@ "holes": "huecos", "million cells": "millones de celdas", "entries per second": "entradas por segundo", + "seconds": "segundos", "files": "archivos", "sizes separated by commas": "tamaños separados por comas", "format ids separated by commas": "ids de formato separados por comas", diff --git a/web/content/fr/site.json b/web/content/fr/site.json index 5dcf1080..b0fd38ee 100644 --- a/web/content/fr/site.json +++ b/web/content/fr/site.json @@ -266,6 +266,7 @@ "holes": "trous", "million cells": "millions de cellules", "entries per second": "entrées par seconde", + "seconds": "secondes", "files": "fichiers", "sizes separated by commas": "tailles séparées par des virgules", "format ids separated by commas": "identifiants de format séparés par des virgules", diff --git a/web/content/hi/site.json b/web/content/hi/site.json index 3e6eb114..44604937 100644 --- a/web/content/hi/site.json +++ b/web/content/hi/site.json @@ -266,6 +266,7 @@ "holes": "छेद", "million cells": "दस लाख सेल", "entries per second": "प्रति सेकंड प्रविष्टियाँ", + "seconds": "सेकंड", "files": "फ़ाइलें", "sizes separated by commas": "अल्पविराम से अलग किए गए आकार", "format ids separated by commas": "अल्पविराम से अलग किए गए फ़ॉर्मैट id", diff --git a/web/content/id/site.json b/web/content/id/site.json index 6e122d5c..db0f6b9b 100644 --- a/web/content/id/site.json +++ b/web/content/id/site.json @@ -266,6 +266,7 @@ "holes": "lubang", "million cells": "juta sel", "entries per second": "entri per detik", + "seconds": "detik", "files": "file", "sizes separated by commas": "ukuran dipisahkan koma", "format ids separated by commas": "id format dipisahkan koma", diff --git a/web/content/it/site.json b/web/content/it/site.json index 5f6005bc..bb1183a2 100644 --- a/web/content/it/site.json +++ b/web/content/it/site.json @@ -266,6 +266,7 @@ "holes": "fori", "million cells": "milioni di celle", "entries per second": "voci al secondo", + "seconds": "secondi", "files": "file", "sizes separated by commas": "dimensioni separate da virgole", "format ids separated by commas": "id di formato separati da virgole", diff --git a/web/content/ja/site.json b/web/content/ja/site.json index 4c70ca54..67bca3c5 100644 --- a/web/content/ja/site.json +++ b/web/content/ja/site.json @@ -266,6 +266,7 @@ "holes": "個の穴", "million cells": "百万セル", "entries per second": "1秒あたりのエントリ数", + "seconds": "秒", "files": "ファイル", "sizes separated by commas": "カンマ区切りのサイズ", "format ids separated by commas": "カンマ区切りの形式ID", diff --git a/web/content/ko/site.json b/web/content/ko/site.json index 2d9e719a..8ac78e87 100644 --- a/web/content/ko/site.json +++ b/web/content/ko/site.json @@ -266,6 +266,7 @@ "holes": "개의 구멍", "million cells": "백만 셀", "entries per second": "초당 항목 수", + "seconds": "초", "files": "파일", "sizes separated by commas": "쉼표로 구분한 크기", "format ids separated by commas": "쉼표로 구분한 형식 id", diff --git a/web/content/nl/site.json b/web/content/nl/site.json index 3fd04c32..dd516c39 100644 --- a/web/content/nl/site.json +++ b/web/content/nl/site.json @@ -266,6 +266,7 @@ "holes": "gaten", "million cells": "miljoen cellen", "entries per second": "items per seconde", + "seconds": "seconden", "files": "bestanden", "sizes separated by commas": "groottes gescheiden door komma's", "format ids separated by commas": "formaat-id's gescheiden door komma's", diff --git a/web/content/pl/site.json b/web/content/pl/site.json index eff09c97..75dc56cc 100644 --- a/web/content/pl/site.json +++ b/web/content/pl/site.json @@ -261,6 +261,7 @@ "holes": "dziur", "million cells": "milionów komórek", "entries per second": "wpisów na sekundę", + "seconds": "sekund", "files": "plików", "sizes separated by commas": "rozmiary rozdzielone przecinkami", "format ids separated by commas": "identyfikatory formatów rozdzielone przecinkami", diff --git a/web/content/pt-BR/site.json b/web/content/pt-BR/site.json index 1f5d7457..25a0d940 100644 --- a/web/content/pt-BR/site.json +++ b/web/content/pt-BR/site.json @@ -266,6 +266,7 @@ "holes": "furos", "million cells": "milhões de células", "entries per second": "entradas por segundo", + "seconds": "segundos", "files": "arquivos", "sizes separated by commas": "tamanhos separados por vírgulas", "format ids separated by commas": "ids de formato separados por vírgulas", diff --git a/web/content/ro/site.json b/web/content/ro/site.json index d8d0c0db..b4b1d566 100644 --- a/web/content/ro/site.json +++ b/web/content/ro/site.json @@ -266,6 +266,7 @@ "holes": "găuri", "million cells": "milioane de celule", "entries per second": "intrări pe secundă", + "seconds": "secunde", "files": "fișiere", "sizes separated by commas": "dimensiuni separate prin virgule", "format ids separated by commas": "id-uri de format separate prin virgule", diff --git a/web/content/ru/site.json b/web/content/ru/site.json index 534355a7..ffa72373 100644 --- a/web/content/ru/site.json +++ b/web/content/ru/site.json @@ -266,6 +266,7 @@ "holes": "отверстий", "million cells": "миллионов ячеек", "entries per second": "записей в секунду", + "seconds": "секунд", "files": "файлов", "sizes separated by commas": "размеры через запятую", "format ids separated by commas": "id форматов через запятую", diff --git a/web/content/th/site.json b/web/content/th/site.json index 7d8b27eb..bbadb084 100644 --- a/web/content/th/site.json +++ b/web/content/th/site.json @@ -266,6 +266,7 @@ "holes": "รู", "million cells": "ล้านเซลล์", "entries per second": "รายการต่อวินาที", + "seconds": "วินาที", "files": "ไฟล์", "sizes separated by commas": "ขนาดที่คั่นด้วยจุลภาค", "format ids separated by commas": "รหัสรูปแบบที่คั่นด้วยจุลภาค", diff --git a/web/content/tr/site.json b/web/content/tr/site.json index 1daae614..20541b48 100644 --- a/web/content/tr/site.json +++ b/web/content/tr/site.json @@ -266,6 +266,7 @@ "holes": "delik", "million cells": "milyon hücre", "entries per second": "saniyedeki girdi", + "seconds": "saniye", "files": "dosya", "sizes separated by commas": "virgülle ayrılmış boyutlar", "format ids separated by commas": "virgülle ayrılmış biçim kimlikleri", diff --git a/web/content/uk/site.json b/web/content/uk/site.json index 1e9ed324..3dcd398f 100644 --- a/web/content/uk/site.json +++ b/web/content/uk/site.json @@ -266,6 +266,7 @@ "holes": "отворів", "million cells": "мільйонів комірок", "entries per second": "записів на секунду", + "seconds": "секунд", "files": "файлів", "sizes separated by commas": "розміри через кому", "format ids separated by commas": "id форматів через кому", diff --git a/web/content/vi/site.json b/web/content/vi/site.json index cc789f8e..198e47c1 100644 --- a/web/content/vi/site.json +++ b/web/content/vi/site.json @@ -266,6 +266,7 @@ "holes": "lỗ", "million cells": "triệu ô", "entries per second": "mục mỗi giây", + "seconds": "giây", "files": "tệp", "sizes separated by commas": "các kích thước cách nhau bằng dấu phẩy", "format ids separated by commas": "các id định dạng cách nhau bằng dấu phẩy", diff --git a/web/content/zh-Hans/site.json b/web/content/zh-Hans/site.json index 7b009dda..ae30e8f5 100644 --- a/web/content/zh-Hans/site.json +++ b/web/content/zh-Hans/site.json @@ -266,6 +266,7 @@ "holes": "个洞", "million cells": "百万个单元格", "entries per second": "条目每秒", + "seconds": "秒", "files": "个文件", "sizes separated by commas": "以逗号分隔的大小", "format ids separated by commas": "以逗号分隔的格式 id", diff --git a/web/content/zh-Hant/site.json b/web/content/zh-Hant/site.json index 8bc54ffa..0c4b7b0f 100644 --- a/web/content/zh-Hant/site.json +++ b/web/content/zh-Hant/site.json @@ -266,6 +266,7 @@ "holes": "個洞", "million cells": "百萬個儲存格", "entries per second": "每秒項目數", + "seconds": "秒", "files": "個檔案", "sizes separated by commas": "以逗號分隔的大小", "format ids separated by commas": "以逗號分隔的格式 id", diff --git a/web/public/ar/formats/index.html b/web/public/ar/formats/index.html index a71c5d54..d50c4b8c 100644 --- a/web/public/ar/formats/index.html +++ b/web/public/ar/formats/index.html @@ -865,13 +865,18 @@

الإعدادات التي تقبلها كل صيغة

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + صحيح أو خاطئ + batch @@ -907,6 +912,71 @@

الإعدادات التي تقبلها كل صيغة

trace_context صحيح أو خاطئ + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + صحيح أو خاطئ + + + + interval + 1 - 86400 ثانية + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + صحيح أو خاطئ + + + + empty_envelope + صحيح أو خاطئ + + + + body + any-value, text + + + + non_finite + صحيح أو خاطئ + pdf pages diff --git a/web/public/cs/formaty/index.html b/web/public/cs/formaty/index.html index 177b5464..387cac91 100644 --- a/web/public/cs/formaty/index.html +++ b/web/public/cs/formaty/index.html @@ -866,13 +866,18 @@

Nastavení, která každý formát přijímá

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + pravda nebo nepravda + batch @@ -908,6 +913,71 @@

Nastavení, která každý formát přijímá

trace_context pravda nebo nepravda + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + pravda nebo nepravda + + + + interval + 1 - 86400 sekund + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + pravda nebo nepravda + + + + empty_envelope + pravda nebo nepravda + + + + body + any-value, text + + + + non_finite + pravda nebo nepravda + pdf pages diff --git a/web/public/de/formate/index.html b/web/public/de/formate/index.html index b9cd6c0e..32047155 100644 --- a/web/public/de/formate/index.html +++ b/web/public/de/formate/index.html @@ -868,13 +868,18 @@

Einstellungen, die jedes Format kennt

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + wahr oder falsch + batch @@ -910,6 +915,71 @@

Einstellungen, die jedes Format kennt

trace_context wahr oder falsch + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + wahr oder falsch + + + + interval + 1 - 86400 Sekunden + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + wahr oder falsch + + + + empty_envelope + wahr oder falsch + + + + body + any-value, text + + + + non_finite + wahr oder falsch + pdf pages diff --git a/web/public/es/formatos/index.html b/web/public/es/formatos/index.html index 88f4ea47..ecb4818d 100644 --- a/web/public/es/formatos/index.html +++ b/web/public/es/formatos/index.html @@ -869,13 +869,18 @@

Ajustes que admite cada formato

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + verdadero o falso + batch @@ -911,6 +916,71 @@

Ajustes que admite cada formato

trace_context verdadero o falso + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + verdadero o falso + + + + interval + 1 - 86400 segundos + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + verdadero o falso + + + + empty_envelope + verdadero o falso + + + + body + any-value, text + + + + non_finite + verdadero o falso + pdf pages diff --git a/web/public/formats/index.html b/web/public/formats/index.html index ca75e80d..94729edf 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -867,13 +867,18 @@

Settings each format accepts

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + true or false + batch @@ -909,6 +914,71 @@

Settings each format accepts

trace_context true or false + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + true or false + + + + interval + 1 - 86400 seconds + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + true or false + + + + empty_envelope + true or false + + + + body + any-value, text + + + + non_finite + true or false + pdf pages diff --git a/web/public/fr/formats/index.html b/web/public/fr/formats/index.html index 846211e0..fbe7cc75 100644 --- a/web/public/fr/formats/index.html +++ b/web/public/fr/formats/index.html @@ -869,13 +869,18 @@

Réglages que chaque format accepte

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + vrai ou faux + batch @@ -911,6 +916,71 @@

Réglages que chaque format accepte

trace_context vrai ou faux + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + vrai ou faux + + + + interval + 1 - 86400 secondes + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + vrai ou faux + + + + empty_envelope + vrai ou faux + + + + body + any-value, text + + + + non_finite + vrai ou faux + pdf pages diff --git a/web/public/hi/formats/index.html b/web/public/hi/formats/index.html index 3d3697b1..74a4d300 100644 --- a/web/public/hi/formats/index.html +++ b/web/public/hi/formats/index.html @@ -867,13 +867,18 @@

हर फ़ॉर्मैट की स्वीकार की जा otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + सही या गलत + batch @@ -909,6 +914,71 @@

हर फ़ॉर्मैट की स्वीकार की जा trace_context सही या गलत + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + सही या गलत + + + + interval + 1 - 86400 सेकंड + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + सही या गलत + + + + empty_envelope + सही या गलत + + + + body + any-value, text + + + + non_finite + सही या गलत + pdf pages diff --git a/web/public/id/format/index.html b/web/public/id/format/index.html index 56d4e6c9..a58dce5d 100644 --- a/web/public/id/format/index.html +++ b/web/public/id/format/index.html @@ -867,13 +867,18 @@

Pengaturan yang diterima setiap format

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + benar atau salah + batch @@ -909,6 +914,71 @@

Pengaturan yang diterima setiap format

trace_context benar atau salah + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + benar atau salah + + + + interval + 1 - 86400 detik + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + benar atau salah + + + + empty_envelope + benar atau salah + + + + body + any-value, text + + + + non_finite + benar atau salah + pdf pages diff --git a/web/public/it/formati/index.html b/web/public/it/formati/index.html index 3770db4b..d2015965 100644 --- a/web/public/it/formati/index.html +++ b/web/public/it/formati/index.html @@ -869,13 +869,18 @@

Impostazioni che ogni formato accetta

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + vero o falso + batch @@ -911,6 +916,71 @@

Impostazioni che ogni formato accetta

trace_context vero o falso + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + vero o falso + + + + interval + 1 - 86400 secondi + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + vero o falso + + + + empty_envelope + vero o falso + + + + body + any-value, text + + + + non_finite + vero o falso + pdf pages diff --git a/web/public/ja/formats/index.html b/web/public/ja/formats/index.html index 3e928a0b..bc438a6c 100644 --- a/web/public/ja/formats/index.html +++ b/web/public/ja/formats/index.html @@ -859,13 +859,18 @@

形式ごとに受け付ける設定

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + 真または偽 + batch @@ -901,6 +906,71 @@

形式ごとに受け付ける設定

trace_context 真または偽 + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + 真または偽 + + + + interval + 1 - 86400 秒 + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + 真または偽 + + + + empty_envelope + 真または偽 + + + + body + any-value, text + + + + non_finite + 真または偽 + pdf pages diff --git a/web/public/ko/formats/index.html b/web/public/ko/formats/index.html index da85e73c..5b4cacb7 100644 --- a/web/public/ko/formats/index.html +++ b/web/public/ko/formats/index.html @@ -860,13 +860,18 @@

각 형식이 받는 설정

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + 참 또는 거짓 + batch @@ -902,6 +907,71 @@

각 형식이 받는 설정

trace_context 참 또는 거짓 + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + 참 또는 거짓 + + + + interval + 1 - 86400 초 + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + 참 또는 거짓 + + + + empty_envelope + 참 또는 거짓 + + + + body + any-value, text + + + + non_finite + 참 또는 거짓 + pdf pages diff --git a/web/public/nl/formaten/index.html b/web/public/nl/formaten/index.html index 062c1b2b..0affeff6 100644 --- a/web/public/nl/formaten/index.html +++ b/web/public/nl/formaten/index.html @@ -869,13 +869,18 @@

Instellingen die elk formaat accepteert

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + waar of onwaar + batch @@ -911,6 +916,71 @@

Instellingen die elk formaat accepteert

trace_context waar of onwaar + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + waar of onwaar + + + + interval + 1 - 86400 seconden + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + waar of onwaar + + + + empty_envelope + waar of onwaar + + + + body + any-value, text + + + + non_finite + waar of onwaar + pdf pages diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index 92624a80..886c565e 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -867,13 +867,18 @@

Ustawienia, które przyjmuje każdy format

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + prawda albo fałsz + batch @@ -909,6 +914,71 @@

Ustawienia, które przyjmuje każdy format

trace_context prawda albo fałsz + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + prawda albo fałsz + + + + interval + 1 - 86400 sekund + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + prawda albo fałsz + + + + empty_envelope + prawda albo fałsz + + + + body + any-value, text + + + + non_finite + prawda albo fałsz + pdf pages diff --git a/web/public/pt-br/formatos/index.html b/web/public/pt-br/formatos/index.html index 933998bc..c9efcfd0 100644 --- a/web/public/pt-br/formatos/index.html +++ b/web/public/pt-br/formatos/index.html @@ -868,13 +868,18 @@

Configurações que cada formato aceita

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + verdadeiro ou falso + batch @@ -910,6 +915,71 @@

Configurações que cada formato aceita

trace_context verdadeiro ou falso + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + verdadeiro ou falso + + + + interval + 1 - 86400 segundos + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + verdadeiro ou falso + + + + empty_envelope + verdadeiro ou falso + + + + body + any-value, text + + + + non_finite + verdadeiro ou falso + pdf pages diff --git a/web/public/ro/formate/index.html b/web/public/ro/formate/index.html index 0dba8530..f7c1f13e 100644 --- a/web/public/ro/formate/index.html +++ b/web/public/ro/formate/index.html @@ -868,13 +868,18 @@

Setările pe care le acceptă fiecare format

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + adevărat sau fals + batch @@ -910,6 +915,71 @@

Setările pe care le acceptă fiecare format

trace_context adevărat sau fals + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + adevărat sau fals + + + + interval + 1 - 86400 secunde + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + adevărat sau fals + + + + empty_envelope + adevărat sau fals + + + + body + any-value, text + + + + non_finite + adevărat sau fals + pdf pages diff --git a/web/public/ru/formats/index.html b/web/public/ru/formats/index.html index 58f1efcf..36125aec 100644 --- a/web/public/ru/formats/index.html +++ b/web/public/ru/formats/index.html @@ -867,13 +867,18 @@

Настройки, которые принимает каждый форм otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + истина или ложь + batch @@ -909,6 +914,71 @@

Настройки, которые принимает каждый форм trace_context истина или ложь + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + истина или ложь + + + + interval + 1 - 86400 секунд + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + истина или ложь + + + + empty_envelope + истина или ложь + + + + body + any-value, text + + + + non_finite + истина или ложь + pdf pages diff --git a/web/public/th/formats/index.html b/web/public/th/formats/index.html index 41874ac3..c9a07917 100644 --- a/web/public/th/formats/index.html +++ b/web/public/th/formats/index.html @@ -865,13 +865,18 @@

การตั้งค่าที่แต่ละรูปแบบร otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + จริงหรือเท็จ + batch @@ -907,6 +912,71 @@

การตั้งค่าที่แต่ละรูปแบบร trace_context จริงหรือเท็จ + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + จริงหรือเท็จ + + + + interval + 1 - 86400 วินาที + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + จริงหรือเท็จ + + + + empty_envelope + จริงหรือเท็จ + + + + body + any-value, text + + + + non_finite + จริงหรือเท็จ + pdf pages diff --git a/web/public/tr/bicimler/index.html b/web/public/tr/bicimler/index.html index 31e83384..8ec3fa85 100644 --- a/web/public/tr/bicimler/index.html +++ b/web/public/tr/bicimler/index.html @@ -867,13 +867,18 @@

Her biçimin kabul ettiği ayarlar

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + doğru veya yanlış + batch @@ -909,6 +914,71 @@

Her biçimin kabul ettiği ayarlar

trace_context doğru veya yanlış + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + doğru veya yanlış + + + + interval + 1 - 86400 saniye + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + doğru veya yanlış + + + + empty_envelope + doğru veya yanlış + + + + body + any-value, text + + + + non_finite + doğru veya yanlış + pdf pages diff --git a/web/public/uk/formats/index.html b/web/public/uk/formats/index.html index d6044fef..89654ea5 100644 --- a/web/public/uk/formats/index.html +++ b/web/public/uk/formats/index.html @@ -867,13 +867,18 @@

Налаштування, які приймає кожен формат

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + істина або хибність + batch @@ -909,6 +914,71 @@

Налаштування, які приймає кожен формат

trace_context істина або хибність + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + істина або хибність + + + + interval + 1 - 86400 секунд + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + істина або хибність + + + + empty_envelope + істина або хибність + + + + body + any-value, text + + + + non_finite + істина або хибність + pdf pages diff --git a/web/public/vi/dinh-dang/index.html b/web/public/vi/dinh-dang/index.html index 3540c772..a3ee69f6 100644 --- a/web/public/vi/dinh-dang/index.html +++ b/web/public/vi/dinh-dang/index.html @@ -865,13 +865,18 @@

Các thiết lập mỗi định dạng nhận

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + đúng hoặc sai + batch @@ -907,6 +912,71 @@

Các thiết lập mỗi định dạng nhận

trace_context đúng hoặc sai + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + đúng hoặc sai + + + + interval + 1 - 86400 giây + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + đúng hoặc sai + + + + empty_envelope + đúng hoặc sai + + + + body + any-value, text + + + + non_finite + đúng hoặc sai + pdf pages diff --git a/web/public/zh-hans/formats/index.html b/web/public/zh-hans/formats/index.html index afd93ba6..25a4b6e0 100644 --- a/web/public/zh-hans/formats/index.html +++ b/web/public/zh-hans/formats/index.html @@ -859,13 +859,18 @@

每种格式接受的设置

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + 真或假 + batch @@ -901,6 +906,71 @@

每种格式接受的设置

trace_context 真或假 + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + 真或假 + + + + interval + 1 - 86400 秒 + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + 真或假 + + + + empty_envelope + 真或假 + + + + body + any-value, text + + + + non_finite + 真或假 + pdf pages diff --git a/web/public/zh-hant/formats/index.html b/web/public/zh-hant/formats/index.html index bc3c2faa..02fdc26c 100644 --- a/web/public/zh-hant/formats/index.html +++ b/web/public/zh-hant/formats/index.html @@ -859,13 +859,18 @@

每種格式接受的設定

otlp signal - logs, traces + logs, metrics, traces formatting indented, minified, record-per-line + + + bom + 真或假 + batch @@ -901,6 +906,71 @@

每種格式接受的設定

trace_context 真或假 + + + metric_kind + exponential-histogram, gauge, histogram, mixed, sum, summary + + + + temporality + cumulative, delta + + + + exemplars + 真或假 + + + + interval + 1 - 86400 秒 + + + + id_encoding + base64, hex, upper-hex + + + + enum_encoding + name, number + + + + key_case + camel, snake + + + + large_numbers + number, text + + + + time_order + in-order, out-of-order + + + + end_before_start + 真或假 + + + + empty_envelope + 真或假 + + + + body + any-value, text + + + + non_finite + 真或假 + pdf pages From 443debef3695049ac09913a67c646e04fd3c6e48 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 11 Nov 2026 20:48:29 +0100 Subject: [PATCH 2/3] otlp records bom in the manifest, and its checks fail rather than crash The manifest of an otlp file now says whether it opens with a byte order mark, and a guard holds the manifest to every setting asked. The refusal of a file that would date collections after 2262 asks for a shorter interval, not a longer one. The Collector oracle expects a batch to vanish under base64 ids only when it holds an id, and reads its sentinel last in the same file, so its return proves every line before it was read. The structural check reads ASCII digits only and names a batch nested too deep instead of crashing. Lint, formatting and the layering of otlp beside textenc are fixed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 ++--- internal/format/otlp/measure.go | 7 +- internal/format/otlp/metricwrite.go | 4 ++ internal/format/otlp/pitfalls.go | 2 +- internal/format/otlp/properties.go | 5 +- internal/format/otlp/settings.go | 2 +- internal/guard/damagewitness_test.go | 2 +- internal/guard/layers_test.go | 2 +- internal/guard/otlp_test.go | 86 +++++++++++++++++++---- internal/gui/text/locale/registry/en.json | 4 +- internal/gui/text/locale/registry/pl.json | 2 +- internal/gui/text/locale/said/en.json | 4 +- internal/gui/text/locale/said/pl.json | 2 +- internal/oracle/otelscripts.go | 43 ++++++++---- internal/oracle/strict.py | 19 +++-- 15 files changed, 147 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 574d9d81..cf9cadd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,14 +28,14 @@ because it turns other people's test suites red. ### Added - **A new format: `otlp`, OpenTelemetry traces, logs and metrics as OTLP - JSON.** `tfg generate --format otlp` writes spans, log records or metrics to - an exact size, one batch on each line as the OTLP file exporter writes them - (`.jsonl`), or one request for an OTLP endpoint (`.json`), chosen by - `formatting`. Metrics are what a Java service reports every `interval`: - gauges, sums, histograms, exponential histograms and summaries, cumulative - or delta. A file comes back from the OpenTelemetry Collector byte for byte, - and no line passes 1 MiB, where the Collector's file receiver silently drops - it. + JSON.** `tfg generate --format otlp` writes them to an exact size, one batch + on each line as the OTLP file exporter does (`.jsonl`), or one request for + an OTLP endpoint (`.json`), as `formatting` says. Metrics are what a Java + service reports every `interval`: gauges, sums, histograms, exponential + histograms and summaries, with sums and histograms cumulative or delta. A + file of one batch on each line comes back from the OpenTelemetry Collector + byte for byte, and no line passes 1 MiB, where the Collector's file receiver + silently drops it. - **`otlp` can write the mistakes OTLP readers disagree about.** Ten settings, each off unless chosen: ids in capitals or base64, enums by diff --git a/internal/format/otlp/measure.go b/internal/format/otlp/measure.go index 62a3f1b1..0bdcb7fd 100644 --- a/internal/format/otlp/measure.go +++ b/internal/format/otlp/measure.go @@ -21,7 +21,10 @@ func (m *metricPlan) measure(rng *rand.Rand, p *point, st *seriesState, cumulati var taken [mostMeasurements]micros var where [mostMeasurements]int m.cycle = [bucketCount]int64{} - for i := range n { + for i := range taken { + if i == n { + break + } b := bands[rng.IntN(len(bands))] taken[i] = b.low + rng.Int64N(b.high-b.low+1) if inst.exemplary { @@ -94,6 +97,8 @@ func (m *metricPlan) shapePoint(p *point, st *seriesState, held []int64, where [ // Prometheus client write one. p.count, p.sum = st.count, st.sum quantiles(p, taken) + case shapeGauge, shapeSum: + // A number is not measured, so it has no buckets and no quantiles. } } diff --git a/internal/format/otlp/metricwrite.go b/internal/format/otlp/metricwrite.go index e3608f4f..89631bc4 100644 --- a/internal/format/otlp/metricwrite.go +++ b/internal/format/otlp/metricwrite.go @@ -38,6 +38,8 @@ func (m *metricPlan) widestPoint(sl slot, wide []int64) point { p.number = widestCount case memoryUsed: p.number = memoryPools[sl.series].high + case requestDuration, gcDuration, requestTimer: + // Measured, so the shape below writes them rather than a number. } switch inst.shape { case shapeHistogram: @@ -49,6 +51,8 @@ func (m *metricPlan) widestPoint(sl slot, wide []int64) point { p.buckets, p.offset = wide, firstBucket case shapeSummary: p.quantiles = [3]micros{longestDuration - 1, longestDuration - 1, longestDuration - 1} + case shapeGauge, shapeSum: + // A number, written above. } return p } diff --git a/internal/format/otlp/pitfalls.go b/internal/format/otlp/pitfalls.go index 92057dd3..f06de24d 100644 --- a/internal/format/otlp/pitfalls.go +++ b/internal/format/otlp/pitfalls.go @@ -67,7 +67,7 @@ func pitfallProperties() []format.Property { var pitfallDetail = map[string]string{ IDEncoding: "How trace and span ids are written. hex is what the OTLP specification asks for. " + "upper-hex writes the same digits in capitals, which the specification allows, and the OpenTelemetry Collector writes them back in lower case. " + - "base64 writes them the way the JSON mapping of protobuf writes bytes, which the specification forbids - the file receiver of the Collector drops every batch without a word and its HTTP receiver answers 400. " + + "base64 writes them the way the JSON mapping of protobuf writes bytes, which the specification forbids - the file receiver of the Collector drops every batch that holds an id without a word and its HTTP receiver answers 400. " + "A reader built on the protobuf library reads base64 ids right and hex ids as something else.", EnumEncoding: "How the kind of a span, the code of its status, the severity of a log record and the temporality of a sum or a histogram are written. number is what the OTLP specification asks for. " + "name writes names such as SPAN_KIND_SERVER, which the specification forbids. The OpenTelemetry Collector reads them anyway and writes numbers back, and the Rust crate opentelemetry-proto refuses a span written that way.", diff --git a/internal/format/otlp/properties.go b/internal/format/otlp/properties.go index ac181145..4544b2e6 100644 --- a/internal/format/otlp/properties.go +++ b/internal/format/otlp/properties.go @@ -16,6 +16,9 @@ func (s settings) recorded() map[string]any { Signal: s.signal, Formatting: s.layout.name, Start: s.startText, + // Every file has a byte order mark or has none, so it is stated here + // rather than among the pitfalls that reach some files only. + BOM: s.bom, // Stated even though it is always false here, so a test can assert on // it without knowing which formats carry a label internally. format.PropertyLabelEmbedded: false, @@ -61,7 +64,7 @@ func (s settings) recordMetrics(out map[string]any) { func (s settings) recordPitfalls(out map[string]any) { values := map[string]any{ IDEncoding: s.idEncoding, EnumEncoding: pick2(s.enumNames, Name, Number), KeyCase: pick2(s.snakeKeys, Snake, Camel), - LargeNumbers: pick2(s.bareNumbers, Number, AsText), BOM: s.bom, TimeOrder: pick2(s.outOfOrder, OutOfOrder, InOrder), + LargeNumbers: pick2(s.bareNumbers, Number, AsText), TimeOrder: pick2(s.outOfOrder, OutOfOrder, InOrder), EndBeforeStart: s.endBeforeStart, EmptyEnvelope: s.emptyEnvelope, Body: pick2(s.anyBody, AnyValue, AsText), NonFinite: s.nonFinite, } for _, p := range pitfallProperties() { diff --git a/internal/format/otlp/settings.go b/internal/format/otlp/settings.go index e449587d..c6f50bdf 100644 --- a/internal/format/otlp/settings.go +++ b/internal/format/otlp/settings.go @@ -363,7 +363,7 @@ func (s settings) collectsInTime(bytes int64) error { return &format.PropertyValueError{ Format: "otlp", Key: Start, Value: s.startText, Reason: core.Says("otlp.AFileThisLargeCollectsAfter", - "a file of this size collected every %d s would date its last collections after the year 2262. Choose an earlier start, a longer interval or a smaller file", + "a file of this size collected every %d s would date its last collections after the year 2262. Choose an earlier start, a shorter interval or a smaller file", core.A("Interval", s.interval)), } } diff --git a/internal/guard/damagewitness_test.go b/internal/guard/damagewitness_test.go index 6ef51795..eb9b8c5f 100644 --- a/internal/guard/damagewitness_test.go +++ b/internal/guard/damagewitness_test.go @@ -93,7 +93,7 @@ var policyRefusals = map[[2]string][]string{ // A reader of lines meets the damage on one line and carries on with the // next, measured 2026-11-11 (docs/OTLP-2026-11-10.md section 12.3). The // checker names the line the damage is on. - {damage.ZeroHead, "log"}: {"line 1 is not a whole entry in any shape this tool writes", "the file does not end with a newline"}, + {damage.ZeroHead, "log"}: {"line 1 is not a whole entry in any shape this tool writes", "the file does not end with a newline"}, // The head the damage zeroes also held the byte order mark or the empty // envelope the pitfalls put first, and the checker names that before it // reaches line 1. diff --git a/internal/guard/layers_test.go b/internal/guard/layers_test.go index 3263e934..e13d2f93 100644 --- a/internal/guard/layers_test.go +++ b/internal/guard/layers_test.go @@ -175,7 +175,7 @@ var sameLayerAllowed = map[string][]string{ "internal/format/csvfile": {"internal/format"}, "internal/format/jsonfile": {"internal/format"}, "internal/format/geojson": {"internal/format"}, - "internal/format/otlp": {"internal/format"}, + "internal/format/otlp": {"internal/format", "internal/format/textenc"}, "internal/format/yamlfile": {"internal/format"}, "internal/format/tomlfile": {"internal/format", "internal/format/textenc"}, "internal/format/xmlfile": {"internal/format", "internal/format/textenc"}, diff --git a/internal/guard/otlp_test.go b/internal/guard/otlp_test.go index 05190b37..7a797b14 100644 --- a/internal/guard/otlp_test.go +++ b/internal/guard/otlp_test.go @@ -4,8 +4,12 @@ import ( "bytes" "context" "errors" + "fmt" + "maps" "os" "path/filepath" + "regexp" + "slices" "strconv" "strings" "testing" @@ -135,8 +139,22 @@ func TestAnOTLPFileIsWhatItsSettingsSay(t *testing.T) { dir := t.TempDir() var reqs []oracle.StrictRequest batched := 0 + // The checker is told what was asked. A check told by the manifest has to + // be told the same, so the manifest is held to every setting asked and to + // bom, which every file has. Its canaries: a manifest without bom, and one + // that records another value than was asked. + if otlpRecordedDisagrees(map[string]any{}, nil) == "" || otlpRecordedDisagrees(map[string]any{otlp.BOM: false}, map[string]string{otlp.BOM: "true"}) == "" { + t.Fatal("the comparison with the manifest found nothing wrong in a manifest made to be wrong") + } for i, props := range append(append([]map[string]string{}, otlpCases...), otlpPitfallCases...) { smallest := d.SmallestAccepted(format.Request{Properties: props}) + p, err := d.Generator.Plan(format.Request{Bytes: smallest, Seed: uint64(57 + i), Label: true, Properties: props}) + if err != nil { + t.Fatalf("planning %v: %v", props, err) + } + if why := otlpRecordedDisagrees(p.Properties, props); why != "" { + t.Errorf("%v: %s", props, why) + } for _, size := range otlpSizes(smallest) { path := filepath.Join(dir, strconv.Itoa(i)+"_"+strconv.FormatInt(size, 10)+".jsonl") body := writeOTLP(t, path, size, uint64(57+i), props) @@ -200,8 +218,8 @@ func TestEveryOTLPSettingSurvivesItsReferenceTool(t *testing.T) { // Every pitfall meets the Collector the way its description says: read and // written back in the spelling of the specification, or dropped without a -// word - every batch for ids in base64, the first one behind a byte order -// mark, and an empty envelope. Measured on 2026-11-11 and asked again here, +// word - every batch with an id for ids in base64, the first one behind a +// byte order mark, and an empty envelope. Measured on 2026-11-11 and asked again here, // because those sentences are what a tester reads before trusting the file. // // The structural check runs first and has to find each pitfall in its file. @@ -210,18 +228,7 @@ func TestEveryOTLPSettingSurvivesItsReferenceTool(t *testing.T) { // // Named to match ReferenceTool, for the reason the guard above gives. func TestEveryOTLPPitfallMeetsItsReferenceTool(t *testing.T) { - dir := t.TempDir() - var reqs []oracle.StrictRequest - for i, props := range otlpPitfallCases { - path := filepath.Join(dir, "pitfall"+strconv.Itoa(i)+".jsonl") - size := int64(20000) - if props[otlp.Signal] == otlp.Metrics { - // Three collections, so a first line and a last one both exist. - size = 60000 - } - writeOTLP(t, path, size, uint64(91+i), props) - reqs = append(reqs, oracle.StrictRequest{Format: "otlp", Path: path, Settings: props}) - } + reqs := otlpPitfallFiles(t, t.TempDir()) got, available, err := oracle.StrictBatch(t.Context(), reqs) if !available { t.Skip("the structural check needs python, and without it no pitfall is known to be in its file") @@ -454,7 +461,14 @@ func TestTheOTLPCheckRefusesWhatAJSONReaderLetsThrough(t *testing.T) { return path } deep := append(bytes.Repeat([]byte("["), 200000), bytes.Repeat([]byte("]"), 200000)...) + // Deep enough for the functions that spell a batch again and shallow + // enough for the JSON reader, which goes about fifty times deeper. + readable := []byte(`{"resourceSpans":` + strings.Repeat("[", 3000) + strings.Repeat("]", 3000) + "}\n") batch := map[string]string{otlp.Batch: "1"} + // A superscript two, which str.isdigit takes for a digit and int() does not. + bare := map[string]string{otlp.Batch: "1", otlp.LargeNumbers: otlp.Number} + numbered := writeOTLP(t, filepath.Join(dir, "numbered.jsonl"), 4096, 9, bare) + superscript := regexp.MustCompile(`"startTimeUnixNano":[0-9]+`).ReplaceAll(numbered, []byte("\"startTimeUnixNano\":\"\xc2\xb2\"")) // because is what the refusal has to say. Refused is not enough: the // checker has later checks, and a file let through by the first one can // be refused by another for a different reason. Measured on 2026-11-11 - @@ -470,10 +484,14 @@ func TestTheOTLPCheckRefusesWhatAJSONReaderLetsThrough(t *testing.T) { {"Infinity in the same place", "Infinity is not a JSON number", oracle.StrictRequest{Format: "otlp", Settings: batch, Path: write("infinity.jsonl", bytes.Replace(good, service, []byte(`{"doubleValue":Infinity}`), 1))}}, {"a document nested 200000 deep", "nested deeper than a JSON reader goes", oracle.StrictRequest{Format: "otlp", Settings: batch, Path: write("deep.jsonl", append(deep, '\n'))}}, + {"a batch nested 3000 deep", "nested deeper than this check follows", oracle.StrictRequest{Format: "otlp", Settings: batch, Path: write("readable.jsonl", readable)}}, + {"a superscript two as a time", "not a time in nanoseconds", oracle.StrictRequest{Format: "otlp", Settings: bare, Path: write("superscript.jsonl", superscript)}}, {"a rate of nought", "rate='0'", oracle.StrictRequest{Format: "otlp", Path: filepath.Join(dir, "good.jsonl"), Settings: map[string]string{otlp.Batch: "1", otlp.Rate: "0"}}}, {"a depth that is a word", "depth='deep'", oracle.StrictRequest{Format: "otlp", Path: filepath.Join(dir, "good.jsonl"), Settings: map[string]string{otlp.Batch: "1", otlp.Depth: "deep"}}}, + {"an interval of a superscript two", "interval='\xc2\xb2'", oracle.StrictRequest{Format: "otlp", Path: filepath.Join(dir, "good.jsonl"), + Settings: map[string]string{otlp.Batch: "1", otlp.Interval: "\xc2\xb2"}}}, } reqs := []oracle.StrictRequest{{Format: "otlp", Path: filepath.Join(dir, "good.jsonl"), Settings: batch}} for _, c := range cases { @@ -498,6 +516,46 @@ func TestTheOTLPCheckRefusesWhatAJSONReaderLetsThrough(t *testing.T) { } } +// otlpPitfallFiles writes a file of every pitfall case, and one of metrics +// too small to reach the exemplars: a batch without ids, which the Collector +// reads whatever id_encoding says (review of #233). +func otlpPitfallFiles(t *testing.T, dir string) []oracle.StrictRequest { + t.Helper() + var reqs []oracle.StrictRequest + for i, props := range otlpPitfallCases { + path := filepath.Join(dir, "pitfall"+strconv.Itoa(i)+".jsonl") + size := int64(20000) + if props[otlp.Signal] == otlp.Metrics { + // Three collections, so a first line and a last one both exist. + size = 60000 + } + writeOTLP(t, path, size, uint64(91+i), props) + reqs = append(reqs, oracle.StrictRequest{Format: "otlp", Path: path, Settings: props}) + } + bare := map[string]string{otlp.Signal: otlp.Metrics, otlp.Exemplars: "true", otlp.IDEncoding: otlp.Base64} + path := filepath.Join(dir, "pitfall-without-ids.jsonl") + body := writeOTLP(t, path, otlpFormat(t).SmallestAccepted(format.Request{Properties: bare}), 91, bare) + if bytes.Contains(body, []byte(`"traceId"`)) || bytes.Contains(body, []byte(`"spanId"`)) { + t.Fatalf("%v at its smallest size names a trace or a span, so it no longer asks about a batch without ids", bare) + } + return append(reqs, oracle.StrictRequest{Format: "otlp", Path: path, Settings: bare}) +} + +// otlpRecordedDisagrees names the first setting asked for that the manifest +// does not record as asked, or a manifest silent about bom. Empty when they +// agree. +func otlpRecordedDisagrees(recorded map[string]any, asked map[string]string) string { + if _, ok := recorded[otlp.BOM]; !ok { + return "the manifest does not say whether the file opens with a byte order mark" + } + for _, k := range slices.Sorted(maps.Keys(asked)) { + if got, ok := recorded[k]; !ok || fmt.Sprint(got) != asked[k] { + return fmt.Sprintf("asked for %s=%s and the manifest records %v", k, asked[k], got) + } + } + return "" +} + func otlpFormat(t *testing.T) format.Descriptor { t.Helper() d, err := format.Get("otlp") diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index 8db895f0..6f88d8a3 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -1741,8 +1741,8 @@ }, "Detail.format/otlp.id_encoding": { "description": "The sentence under the id_encoding setting of the otlp format, after what it takes.", - "hash": "sha256-8dac2112d3f4", - "other": "How trace and span ids are written. hex is what the OTLP specification asks for. upper-hex writes the same digits in capitals, which the specification allows, and the OpenTelemetry Collector writes them back in lower case. base64 writes them the way the JSON mapping of protobuf writes bytes, which the specification forbids - the file receiver of the Collector drops every batch without a word and its HTTP receiver answers 400. A reader built on the protobuf library reads base64 ids right and hex ids as something else." + "hash": "sha256-76bf0def8c16", + "other": "How trace and span ids are written. hex is what the OTLP specification asks for. upper-hex writes the same digits in capitals, which the specification allows, and the OpenTelemetry Collector writes them back in lower case. base64 writes them the way the JSON mapping of protobuf writes bytes, which the specification forbids - the file receiver of the Collector drops every batch that holds an id without a word and its HTTP receiver answers 400. A reader built on the protobuf library reads base64 ids right and hex ids as something else." }, "Detail.format/otlp.interval": { "description": "The sentence under the interval setting of the otlp format, after what it takes.", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index 9e3bee46..bb522725 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -347,7 +347,7 @@ "Detail.format/otlp.enum_encoding": { "hash": "sha256-428ff808c144", "other": "Jak zapisany jest rodzaj spanu, kod jego statusu, poziom ważności rekordu logu i temporalność sumy albo histogramu. Liczba to zapis, którego wymaga specyfikacja OTLP. Nazwa zapisuje nazwy takie jak SPAN_KIND_SERVER, czego specyfikacja zabrania. OpenTelemetry Collector mimo to je czyta i zapisuje z powrotem liczby, a crate Rust opentelemetry-proto odrzuca tak zapisany span." }, "Detail.format/otlp.exemplars": { "hash": "sha256-ac0f521a7266", "other": "Czy czasy trwania żądań podają ślad i span, w którym zmierzono pomiar, po jednym na każdy kubełek, w który trafił zbiór. Plik nie ma spanów, więc te identyfikatory nie pasują do niczego w nim. Ma je tylko http.server.request.duration, a przy rodzaju metryk bez niego exemplary są odrzucane." }, "Detail.format/otlp.formatting": { "hash": "sha256-b094d2e4c7a2", "other": "Jak ułożony jest plik. Przy rekord na wiersz każda paczka stoi w osobnej linii, tak jak zapisuje ją eksporter plików OTLP i jak czyta ją odbiornik plików OpenTelemetry Collectora, a nazwa pliku kończy się na .jsonl. Przy zminifikowany i z wcięciami cały plik jest jednym żądaniem, czyli treścią do wysłania na endpoint OTLP, a nazwa pliku kończy się na .json. Wysłany jako treść żądania plik o kilku liniach traci w Collectorze każdą linię po pierwszej, a jego odbiornik plików nie czyta nic z pliku z wcięciami." }, - "Detail.format/otlp.id_encoding": { "hash": "sha256-8dac2112d3f4", "other": "Jak zapisane są identyfikatory śladów i spanów. Szesnastkowo to zapis, którego wymaga specyfikacja OTLP. Szesnastkowo wielkimi literami pisze te same cyfry wielkimi literami, na co specyfikacja pozwala, a OpenTelemetry Collector zapisuje je z powrotem małymi. Base64 pisze je tak, jak mapowanie JSON protobuf zapisuje bajty, czego specyfikacja zabrania - odbiornik plików Collectora bez słowa gubi każdą paczkę, a jego odbiornik HTTP odpowiada 400. Czytnik zbudowany na bibliotece protobuf czyta identyfikatory base64 poprawnie, a szesnastkowe jako coś innego." }, + "Detail.format/otlp.id_encoding": { "hash": "sha256-76bf0def8c16", "other": "Jak zapisane są identyfikatory śladów i spanów. Szesnastkowo to zapis, którego wymaga specyfikacja OTLP. Szesnastkowo wielkimi literami pisze te same cyfry wielkimi literami, na co specyfikacja pozwala, a OpenTelemetry Collector zapisuje je z powrotem małymi. Base64 pisze je tak, jak mapowanie JSON protobuf zapisuje bajty, czego specyfikacja zabrania - odbiornik plików Collectora bez słowa gubi każdą paczkę, w której jest identyfikator, a jego odbiornik HTTP odpowiada 400. Czytnik zbudowany na bibliotece protobuf czyta identyfikatory base64 poprawnie, a szesnastkowe jako coś innego." }, "Detail.format/otlp.interval": { "hash": "sha256-2ce89e34952b", "other": "Ile czasu usługa czeka między dwoma zbiorami swoich metryk. 60 to wartość, której SDK używa domyślnie. Każdy punkt zbioru ma tę samą datę, a w układzie rekord na wiersz linia to jeden zbiór." }, "Detail.format/otlp.key_case": { "hash": "sha256-4f17fdd9113b", "other": "Jak zapisane są klucze pliku. camelCase to zapis, którego wymaga specyfikacja OTLP, na przykład traceId. snake_case pisze nazwy, które pliki .proto nadają polom, na przykład trace_id, a specyfikacja nazywa je niepoprawnymi. OpenTelemetry Collector je czyta i zapisuje z powrotem w camelCase, a crate Rust opentelemetry-proto odrzuca plik." }, "Detail.format/otlp.large_numbers": { "hash": "sha256-da573f0e1080", "other": "Jak zapisane są czasy i inne liczby 64-bitowe. Tekst to zapis, którego specyfikacja OTLP wymaga od nadawcy, na przykład \"1785585600000000000\". Liczba pisze je jako liczby JSON, które czytnik też ma przyjmować, i OpenTelemetry Collector je przyjmuje. Czytnik, który trzyma każdą liczbę JSON jako double, tak jak JavaScript, dostaje każdy czas z dokładnością do 256 nanosekund." }, diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index b950ac2e..00637d7d 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -2473,8 +2473,8 @@ }, "otlp.AFileThisLargeCollectsAfter": { "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Interval}}, written the way the program writes them - keep each spelled exactly that way.", - "hash": "sha256-a57bf3d8c73f", - "other": "a file of this size collected every {{.Interval}} s would date its last collections after the year 2262. Choose an earlier start, a longer interval or a smaller file" + "hash": "sha256-a18c86e7715d", + "other": "a file of this size collected every {{.Interval}} s would date its last collections after the year 2262. Choose an earlier start, a shorter interval or a smaller file" }, "otlp.AFileThisLargeEndsAfter": { "description": "Said by internal/format/otlp - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Rate}}, written the way the program writes them - keep each spelled exactly that way.", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index 08c8c6a6..a34943b7 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -492,7 +492,7 @@ "mp4.MinimumReasonPictures": { "hash": "sha256-2d553380ec9d", "other": "film o długości {{.Duration}} przy {{.FPS}} klatkach na sekundę z klatką kluczową co {{.Interval}} i nowym obrazem {{.Width}}x{{.Height}} co {{.Change}} zajmuje {{.Bytes}} B, a plik zawsze niesie pudełko free, które kosztuje {{.Free}} B nawet puste" }, "otlp.AFileHoldsWholeRecords": { "hash": "sha256-8bd4f63f9cb2", "other": "plik składa się z całych rekordów, a najdłuższy, jaki te ustawienia mogą zapisać, potrzebuje tyle" }, "otlp.AFileThisLargeAddsUpMore": { "hash": "sha256-f2957a096ca2", "other": "plik tej wielkości dodałby w jednej serii {{.Metric}} więcej czasu, niż da się zapisać z dokładnością do mikrosekundy. Wybierz mniejszy plik albo temporalność przyrostowo, gdzie dany rodzaj ją ma" }, - "otlp.AFileThisLargeCollectsAfter": { "hash": "sha256-a57bf3d8c73f", "other": "plik tej wielkości zbierany co {{.Interval}} s datowałby swoje ostatnie zbiory po roku 2262. Wybierz wcześniejszy start, dłuższy odstęp albo mniejszy plik" }, + "otlp.AFileThisLargeCollectsAfter": { "hash": "sha256-a18c86e7715d", "other": "plik tej wielkości zbierany co {{.Interval}} s datowałby swoje ostatnie zbiory po roku 2262. Wybierz wcześniejszy start, krótszy odstęp albo mniejszy plik" }, "otlp.AFileThisLargeEndsAfter": { "hash": "sha256-511e89a36bd2", "other": "plik tej wielkości przy tempie {{.Rate}} datowałby ostatnie rekordy po roku 2262. Wybierz wcześniejszy początek, wyższe tempo albo mniejszy plik" }, "otlp.ALineOfMetricsIsOneCollection": { "hash": "sha256-3a22a0f2e186", "other": "linia przy {{.Signal}} to jeden zbiór, więc nie ma paczki do wybrania" }, "otlp.ChooseAStartInThoseYears": { "hash": "sha256-c72b07e145c8", "other": "Wybierz początek w tych latach, na przykład 2026-08-01T12:00:00Z." }, diff --git a/internal/oracle/otelscripts.go b/internal/oracle/otelscripts.go index 6eddce9d..a0b779ac 100644 --- a/internal/oracle/otelscripts.go +++ b/internal/oracle/otelscripts.go @@ -99,6 +99,22 @@ def canonical(value, key=None): def spelled(line): return json.dumps(canonical(json.loads(line)), separators=(",", ":"), ensure_ascii=False).encode("utf-8") +def carries_ids(value): + # Whether a batch names a trace or a span anywhere. The Collector drops a + # batch whose ids are not hex and has nothing to drop in one with none, + # such as metrics a size too small to reach the exemplars (review of #233). + stack = [value] + while stack: + v = stack.pop() + if isinstance(v, dict): + for k, x in v.items(): + if k in ID_KEYS and x: + return True + stack.append(x) + elif isinstance(v, list): + stack.extend(v) + return False + named = os.environ.get("TFG_OTELCOL", "") collector = named or shutil.which("otelcol-contrib") if not collector: @@ -173,7 +189,7 @@ if told: why = None if not next(iter(doc.values())): why = "an empty envelope" - elif told.get("id_encoding") == "base64": + elif told.get("id_encoding") == "base64" and carries_ids(doc): why = "ids in base64" elif bom and number == 1: why = "the line behind the byte order mark" @@ -187,11 +203,12 @@ try: os.mkdir(os.path.join(work, "in")) with open(os.path.join(work, "in", "data.jsonl"), "wb") as f: f.write(b"\n".join(fed) + b"\n") - if told: - # A batch the Collector reads whatever the file holds: once it is back, - # the file beside it has been read too, so a file that has to come - # back empty does not wait for the whole budget. - with open(os.path.join(work, "in", "zz-sentinel.jsonl"), "wb") as f: + if told: + # A batch the Collector reads whatever the file holds, last in the + # same file. The receiver reads every file on a reader of its own + # and the lines of one in order, so once this batch is back every + # line before it has been read - and a file that has to come back + # empty does not wait for the whole budget (review of #233). f.write(SENTINELS[signal].encode("utf-8") + b"\n") out = os.path.join(work, "out.jsonl") longest = max(len(w) for w in wanted) @@ -218,18 +235,16 @@ try: got, deadline = [], STARTED + BUDGET try: - seen, still = 0, time.time() while time.time() < deadline and process.poll() is None: time.sleep(0.2) got = written() + # Told, the sentinel ends the input and its return says all of it + # was read. Otherwise every batch has to come back. if told: - # Done once the sentinel is back and nothing more has come for - # a second and a half. - if len(got) != seen: - seen, still = len(got), time.time() - elif any(b"tfg.sentinel" in g for g in got) and time.time() - still > 1.5: - break - elif len(got) >= len(wanted): + back = any(b"tfg.sentinel" in g for g in got) + else: + back = len(got) >= len(wanted) + if back: # One more look, so a batch the Collector wrote twice shows. time.sleep(0.6) got = written() diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index 634f59e8..39034938 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -4209,6 +4209,7 @@ def rtf_every(text, settings): OTLP_WIDE = {"startTimeUnixNano", "endTimeUnixNano", "timeUnixNano", "observedTimeUnixNano", "intValue", "asInt", "count", "bucketCounts", "zeroCount"} OTLP_ID_KEYS = {"traceId", "spanId", "parentSpanId"} +OTLP_DIGITS = re.compile(r"[0-9]{1,20}") OTLP_PITFALLS = {"id_encoding": ("hex", "upper-hex", "base64"), "enum_encoding": ("number", "name"), "key_case": ("camel", "snake"), "large_numbers": ("text", "number"), "bom": ("false", "true"), "time_order": ("in-order", "out-of-order"), "end_before_start": ("false", "true"), "empty_envelope": ("false", "true"), "body": ("text", "any-value"), @@ -4251,7 +4252,9 @@ def otlp_respell(value, settings, canonical, key=None): if key in OTLP_WIDE and settings["large_numbers"] == "number": if canonical: return str(value) if type(value) is int else value - return int(value) if isinstance(value, str) and value.isdigit() else value + # ASCII digits alone: str.isdigit also takes a superscript two, which + # int() refuses (review of #233). Twenty is the most a uint64 has. + return int(value) if isinstance(value, str) and OTLP_DIGITS.fullmatch(value) else value if key in OTLP_ID_KEYS and settings["id_encoding"] != "hex" and isinstance(value, str): if settings["id_encoding"] == "upper-hex": return value.lower() if canonical else value.upper() @@ -4425,7 +4428,7 @@ def otlp_text(what, value): def otlp_whole(what, value): """A whole number, which the JSON encoding of OTLP writes as text.""" - if list(value) != ["intValue"] or not isinstance(value["intValue"], str) or not value["intValue"].isdigit(): + if list(value) != ["intValue"] or not isinstance(value["intValue"], str) or not OTLP_DIGITS.fullmatch(value["intValue"]): fail(f"{what} has a whole number that is not written as text of digits") return int(value["intValue"]) @@ -4607,7 +4610,7 @@ def otlp_double(what, value, low=0.0, high=1e16): def otlp_count(what, value): - if not isinstance(value, str) or not value.isdigit() or (len(value) > 1 and value[0] == "0"): + if not isinstance(value, str) or not OTLP_DIGITS.fullmatch(value) or (len(value) > 1 and value[0] == "0"): fail(f"{what} is {value!r}, not a count written as text of digits") return int(value) @@ -4770,7 +4773,7 @@ def check_otlp(data, settings=None): # (review of #230). for name, default, most in (("batch", "512", 1000), ("rate", "1", 1000000), ("depth", "3", 100), ("interval", "60", 86400)): told = given.get(name, default) - if not told.isdigit() or not 1 <= int(told) <= most: + if not OTLP_DIGITS.fullmatch(told) or not 1 <= int(told) <= most: fail(f"the otlp check was told {name}={told!r}, and {name} is a whole number from 1 to {most}") settings[name] = int(told) if settings["signal"] not in OTLP_NAMES: @@ -4802,7 +4805,13 @@ def check_otlp(data, settings=None): if settings["formatting"] != "record-per-line" or not text.startswith(empty): fail(f"empty_envelope=true and the file does not open with the line {empty.strip()}") text = text[len(empty):] - batches = otlp_canonical(otlp_batches(text, settings), settings) + try: + batches = otlp_canonical(otlp_batches(text, settings), settings) + except RecursionError: + # The JSON reader of Python goes about fifty times deeper than the + # functions that write and spell a batch again, measured 2026-11-11 + # (review of #233). + fail("a batch is nested deeper than this check follows") step = 10**9 // settings["rate"] if settings["signal"] != "metrics" else settings["interval"] * 10**9 # The checks name the shapes this tool could get wrong. A shape nobody # thought of still has to come out as a refusal with a reason rather than From 4c54ae21c80e408cc1eec52549be535e828878c2 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 11 Nov 2026 23:07:38 +0100 Subject: [PATCH 3/3] The otlp check is asked about a batch every JSON reader of CI can read A batch nested 3000 levels deep was meant to pass the JSON reader and stop the functions that spell a batch again. CPython 3.12 and 3.13 on Windows stop their reader at 3000 levels, so on the Windows runner the reader refused it first and the check was never reached. 1200 levels is past the thousand a Python function may go and inside every reader's limit. Co-Authored-By: Claude Opus 5.5 --- internal/guard/otlp_test.go | 9 +++++---- internal/oracle/strict.py | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/guard/otlp_test.go b/internal/guard/otlp_test.go index 7a797b14..42e98c4a 100644 --- a/internal/guard/otlp_test.go +++ b/internal/guard/otlp_test.go @@ -461,9 +461,10 @@ func TestTheOTLPCheckRefusesWhatAJSONReaderLetsThrough(t *testing.T) { return path } deep := append(bytes.Repeat([]byte("["), 200000), bytes.Repeat([]byte("]"), 200000)...) - // Deep enough for the functions that spell a batch again and shallow - // enough for the JSON reader, which goes about fifty times deeper. - readable := []byte(`{"resourceSpans":` + strings.Repeat("[", 3000) + strings.Repeat("]", 3000) + "}\n") + // Deeper than the thousand levels Python lets a function go, and shallow + // enough for its JSON reader, which CPython 3.12 and 3.13 stop at 3000 + // levels on Windows and 10000 elsewhere (Include/cpython/pystate.h). + readable := []byte(`{"resourceSpans":` + strings.Repeat("[", 1200) + strings.Repeat("]", 1200) + "}\n") batch := map[string]string{otlp.Batch: "1"} // A superscript two, which str.isdigit takes for a digit and int() does not. bare := map[string]string{otlp.Batch: "1", otlp.LargeNumbers: otlp.Number} @@ -484,7 +485,7 @@ func TestTheOTLPCheckRefusesWhatAJSONReaderLetsThrough(t *testing.T) { {"Infinity in the same place", "Infinity is not a JSON number", oracle.StrictRequest{Format: "otlp", Settings: batch, Path: write("infinity.jsonl", bytes.Replace(good, service, []byte(`{"doubleValue":Infinity}`), 1))}}, {"a document nested 200000 deep", "nested deeper than a JSON reader goes", oracle.StrictRequest{Format: "otlp", Settings: batch, Path: write("deep.jsonl", append(deep, '\n'))}}, - {"a batch nested 3000 deep", "nested deeper than this check follows", oracle.StrictRequest{Format: "otlp", Settings: batch, Path: write("readable.jsonl", readable)}}, + {"a batch nested 1200 deep", "nested deeper than this check follows", oracle.StrictRequest{Format: "otlp", Settings: batch, Path: write("readable.jsonl", readable)}}, {"a superscript two as a time", "not a time in nanoseconds", oracle.StrictRequest{Format: "otlp", Settings: bare, Path: write("superscript.jsonl", superscript)}}, {"a rate of nought", "rate='0'", oracle.StrictRequest{Format: "otlp", Path: filepath.Join(dir, "good.jsonl"), Settings: map[string]string{otlp.Batch: "1", otlp.Rate: "0"}}}, diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index 39034938..4826ad63 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -4808,9 +4808,9 @@ def check_otlp(data, settings=None): try: batches = otlp_canonical(otlp_batches(text, settings), settings) except RecursionError: - # The JSON reader of Python goes about fifty times deeper than the - # functions that write and spell a batch again, measured 2026-11-11 - # (review of #233). + # The JSON reader of Python goes deeper than the functions that write + # and spell a batch again, which stop at a thousand levels (review of + # #233). fail("a batch is nested deeper than this check follows") step = 10**9 // settings["rate"] if settings["signal"] != "metrics" else settings["interval"] * 10**9 # The checks name the shapes this tool could get wrong. A shape nobody