Transform rules change your logs, spans and metric points as they arrive, before they’re stored
or billed. They’re written in the
OpenTelemetry Transformation Language
(OTTL), the language of the OpenTelemetry Collector’s transform and filter processors, and set
on a dataset with a section for each signal.
- Drop what isn’t worth storing: debug logs, health-check spans, metrics you don’t use.
- Redact: delete, hash or mask attributes and text in log bodies (emails, tokens, card numbers) before they’re written anywhere.
- Reshape: set, rename, copy and delete attributes; set severity; rename metrics.
- Parse log bodies: JSON,
key=valueand regular expressions into attributes.
What to expect:
- Nothing changes where you send from. SDKs, Collectors and the EZGH Obs Agent send as they do now.
- Rules aren’t retroactive. They apply to records that arrive after you save them. Records already stored keep what they were stored with, including values a new rule would redact, until their retention ends.
- Only stored data is billed. Dropped records and removed attributes aren’t counted in the ingested or stored bytes.
- Rate limits count bytes received. Your organization’s and dataset’s rates are charged before rules run, so dropping records doesn’t free up rate.
- Off by default. A dataset without rules, or with rules turned off, stores records as they arrive.
Each dataset (one environment of a project) has its own rules, so production can redact while
staging keeps everything.


A rule document
{
"enabled": true,
"logs": {
"statements": [
"replace_pattern(log.body, \"\\\\b\\\\d{13,16}\\\\b\", \"****\")",
"merge_maps(log.attributes, ParseJSON(log.body), \"upsert\") where IsMatch(log.body, \"^\\\\{\")",
"set(log.severity_number, SEVERITY_NUMBER_WARN) where log.attributes[\"level\"] == \"warn\"",
"set(log.attributes[\"user.id\"], SHA256(log.attributes[\"user.id\"])) where log.attributes[\"user.id\"] != nil",
"delete_key(log.attributes, \"user.email\")"
],
"drop": [
"log.severity_number < SEVERITY_NUMBER_INFO",
"IsMatch(log.body, \"GET /healthz\")"
]
},
"traces": {
"statements": ["delete_key(span.attributes, \"http.request.header.authorization\")"],
"drop": ["span.name == \"GET /healthz\""]
},
"metrics": {
"statements": ["set(metric.name, \"http.server.request.duration\") where metric.name == \"http_server_duration\""],
"drop": ["IsMatch(metric.name, \"^go_gc_.*\")"]
}
}For logs, this masks 13- to 16-digit numbers in the body, then parses JSON bodies into attributes,
sets the severity of records whose level is warn, hashes the user ID and deletes the user’s
email, then drops records below INFO and health-check lines. For traces it deletes the
Authorization header attribute and drops health-check spans; for metrics it renames a metric and
drops the Go garbage collector’s.
| Field | Value |
|---|---|
enabled |
true to run the rules, false to keep them saved without running them. Required |
logs, traces, metrics |
A section for each signal, each optional |
statements |
OTTL statements that change records, run in order. Up to 50 |
drop |
OTTL conditions. A record is dropped when any of them holds. Up to 50 |
Each statement and condition is a JSON string, so its quotes and backslashes are escaped once more
in the document. The console’s editor, and the recipes below, show them as plain OTTL, one per
line: the first statement above is replace_pattern(log.body, "\\b\\d{13,16}\\b", "****").
How rules run
Contexts
Every path starts with its context, the kind of element it reads or changes:
| Prefix | Is | In |
|---|---|---|
resource. |
A resource: resource.attributes |
Every signal |
scope. |
An instrumentation scope: scope.name, scope.version, scope.attributes |
Every signal |
log. |
A log record: log.body, log.attributes, log.severity_number, log.severity_text, log.time |
Logs |
span. |
A span: span.name, span.attributes, span.kind, span.status |
Traces |
spanevent. |
A span event: spanevent.name, spanevent.attributes |
Traces |
metric. |
A metric: metric.name, metric.unit, metric.description |
Metrics |
datapoint. |
A metric’s data point: datapoint.attributes |
Metrics |
Each statement and each drop condition runs in the context its own paths need: a statement with
span. paths runs once for every span, one with only resource. paths once for every resource. A
statement can read its element’s parents, so this one runs for every log record:
set(log.attributes["region"], resource.attributes["cloud.region"])Statements, then drop conditions
- Statements run first, in the order you write them, then the drop conditions run on the result. A drop condition can use a field a statement parsed out of the body, or a severity a statement set.
- Order matters: a statement sees what earlier statements did. See Redact before you parse.
- A drop condition removes the element of its context when it holds: a resource (with all its records), a scope, a log record, a span (with its events and links), a span event, a metric (with all its points) or a data point. A scope, resource or metric left with nothing is removed too. A record is dropped when any condition holds.
When a rule fails
- A statement that fails is skipped, and the record goes on through the rest of the rules and
is stored. For example,
merge_maps(log.attributes, ParseJSON(log.body), "upsert")on a body that’s plain text adds nothing; the record is stored as it was. Awhereclause that checks the value first (where IsMatch(log.body, "^\\{")) keeps that from happening. - A drop condition that fails counts as false, so an error never drops a record.
- Some functions do nothing rather than fail.
replace_patternon a body that isn’t a string (a map, for example) leaves it unchanged. - So a redaction can miss. If a statement that redacts a value fails on a record, or doesn’t
match it, the value is stored as sent. Try your rules on real samples with
Test before you rely on them, and prefer rules that can’t fail, such as
delete_key. - Rules that can’t run don’t let records through. If a dataset’s saved rules stop compiling
(only possible when Observability upgrades OTTL), its records are rejected in
partial_successwith the reasontransform rules can't run; check them with GET …/transforms, while records for other datasets in the same request are stored. This lasts until you fix the rules or turn them off. Testing or saving the rules shows which ones don’t compile. Turning them off always saves, because rules that are off aren’t compiled: the dataset’s records are then stored as sent, without any of the rules. - Rules can’t grow records without limit. If a dataset’s rules add more than the
growth limit to a request (for example by repeating a value into itself), they stop,
and that dataset’s records in the request are rejected in
partial_successwith the reasontransform rules made the records too large. They aren’t stored, since their rules didn’t finish. Other datasets in the same request are stored. Test shows the same error.
What rules can’t change
After a resource’s rules run, its ezgh.metadata.environment attribute is set back to what was
sent (or removed again, if it wasn’t sent). A rule can’t move records to another dataset, whichever
function touched the attribute. Every other attribute can change, including service.name,
service.namespace and ezgh.metadata.application.
After the rules, invalid UTF-8 in strings is replaced with U+FFFD, and a value nested more than
1,000 levels deep (from ParseJSON, for example) is stored as its JSON text.
After the rules
Everything after the rules sees the transformed records: record limits, the event-time window, trace sampling, storage, search, PromQL, span metrics and the service catalog.
- A record a rule dropped is accepted, not rejected: it isn’t listed in
partial_success. - A record a rule pushed over a limit is rejected in
partial_successwith its usual reason, as if it had been sent that way: a map with more than 256 attributes aftermerge_maps, for example, or an event time a rule moved outside the dataset’s window.
Recipes
Each recipe shows a signal’s statements or drop conditions as plain OTTL, one per line, as the console’s editor shows them.
Drop debug logs
log.severity_number > SEVERITY_NUMBER_UNSPECIFIED and log.severity_number < SEVERITY_NUMBER_INFORecords sent without a severity have SEVERITY_NUMBER_UNSPECIFIED (0), so the condition
log.severity_number < SEVERITY_NUMBER_INFO alone drops them too.
Drop health-check spans
span.name == "GET /healthz"
IsMatch(span.attributes["url.path"], "^/(healthz|readyz|livez)$")A dropped span’s events and links go with it. Its child spans are kept, without their parent.
Delete and mask secrets
delete_key(log.attributes, "user.email")
delete_matching_keys(log.attributes, "(?i).*(password|secret|token).*")
replace_pattern(log.body, "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}", "<email>")
replace_all_patterns(log.attributes, "value", "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}", "<email>")delete_key(span.attributes, "http.request.header.authorization")
replace_pattern(span.attributes["url.query"], "token=[^&]*", "token=****")Redact before you parse
Mask the body before you parse it into attributes. Parsing copies the value into an attribute, so a mask applied to the body afterward leaves the copy unmasked.
merge_maps(log.attributes, ParseJSON(log.body), "upsert") where IsMatch(log.body, "^\\{")
replace_pattern(log.body, "\\b\\d{13,16}\\b", "****")replace_pattern(log.body, "\\b\\d{13,16}\\b", "****")
merge_maps(log.attributes, ParseJSON(log.body), "upsert") where IsMatch(log.body, "^\\{")For the body {"msg":"order placed","card":"4111111111111111"}:
| Body stored | card attribute stored |
|
|---|---|---|
| Wrong order | {"msg":"order placed","card":"****"} |
4111111111111111 |
| Right order | {"msg":"order placed","card":"****"} |
**** |
Hash a user ID
set(log.attributes["user.id"], SHA256(String(log.attributes["user.id"]))) where log.attributes["user.id"] != nilThe same ID always gets the same hash, so you can still count and search by user. String makes
a numeric ID a string first: without it, SHA256 fails on a number and the ID is stored as sent.
A hash of a value that’s easy to guess, such as a short number or a known email, can be matched by
hashing candidates; delete those instead.
Parse a JSON body
merge_maps(log.attributes, ParseJSON(log.body), "upsert") where IsMatch(log.body, "^\\s*\\{")
merge_maps(log.attributes, log.body, "upsert") where IsMap(log.body)The first parses a body that’s JSON text; the second copies a body that was sent as a map.
"upsert" overwrites attributes of the same name; "insert" keeps the ones already there. A JSON
object nested in the body becomes a map attribute.
Parse key=value
merge_maps(log.attributes, ParseKeyValue(log.body), "upsert") where IsMatch(log.body, "^\\w+=")The body level=warn user=u_1842 msg="cache miss" gets the attributes level (warn), user
(u_1842) and msg (cache miss).
Parse with a regular expression
merge_maps(log.attributes, ExtractPatterns(log.body, "^(?P<method>[A-Z]+) (?P<path>\\S+) (?P<status>\\d{3})"), "upsert")The body GET /api/cart 404 gets method, path and status. Each named group becomes an
attribute. Regular expressions use RE2 syntax.
Set severity from a parsed field
After a statement that parses the body:
set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.attributes["level"] == "error"
set(log.severity_number, SEVERITY_NUMBER_WARN) where log.attributes["level"] == "warn"
set(log.severity_number, SEVERITY_NUMBER_INFO) where log.attributes["level"] == "info"
set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.attributes["level"] == "debug"
set(log.severity_text, ToUpperCase(log.attributes["level"])) where log.attributes["level"] != nilDrop conditions run after these, so a debug-log condition then drops the records parsed as
debug.
Rename a span attribute
set(span.attributes["http.request.method"], span.attributes["http.method"]) where span.attributes["http.method"] != nil
delete_key(span.attributes, "http.method")Drop a metric by name
metric.name == "go_gc_duration_seconds"
IsMatch(metric.name, "^process\\.runtime\\.")Rename a metric
set(metric.name, "http.server.request.duration") where metric.name == "http_server_duration"Points stored under the old name stay under it. Don’t rename a metric onto one you already send with the same attributes: their points would merge.
Metrics: don’t remove identifying labels
Rules see metric names and attributes as sent, not as PromQL reads them:
http.server.request.duration and http.response.status_code, not
http_server_request_duration_seconds and http_response_status_code. A remote-write series’
name is metric.name, and its labels are its points’ datapoint.attributes, job and instance
included. job and instance are also on its resource, as service.name (and
service.namespace, for a job of namespace/name) and service.instance.id;
ezgh_environment and ezgh_application are only on the resource, as the ezgh.metadata.*
attributes.
A point’s attributes and resource tell its series apart. Removing or overwriting an attribute
that tells live series apart isn’t supported. If only a point’s k8s.pod.name attribute tells
two pods’ series apart, deleting it stores both pods’ points as one series: queries can’t tell
them apart, and the two pods’ cumulative counters interleave, so rate() and increase() are
wrong, not approximate.
- Rules don’t stop you: they can’t tell an identifying attribute from one that’s the same on every series. Deleting an attribute that never tells series apart is safe.
- Hashing a value (
SHA256) keeps series apart. Replacing different values with one fixed string, such as****, doesn’t. - To reduce cardinality, aggregate before sending, in your SDK’s views or your Collector. Rules can’t aggregate.
- Test a metrics section on sample points: it reports points from different series that the rules put in the same series at the same time.


With sampling, span metrics and the service catalog
- Rules run before trace sampling. Sampling rules see the transformed spans, and spans a rule drops never reach sampling.
- Spans a rule drops aren’t counted in span metrics, the service map or the service catalog’s stats. Spans sampling drops still are.
- Changing
service.nameorservice.namespacechanges the service’s key: the catalog shows the new service, and the old one ages out as its data does. - Prometheus remote write is transformed like OTLP metrics. A point a rule drops is counted
as written in the
X-Prometheus-Remote-Write-Samples-Written,…-Histograms-Writtenand…-Exemplars-Writtenheaders, so senders don’t see a partial write.
Copying rules from a Collector
Rules use the Collector’s OTTL with its standard functions (editors such as set,
delete_key and replace_pattern, and converters such as ParseJSON, SHA256 and IsMatch),
from OpenTelemetry Collector Contrib v0.162.0.
- Write paths with their context prefix, as the
transformprocessor’s flat configuration does:log.body, notbody. Atransformprocessor’slog_statements,trace_statementsandmetric_statementsgo instatements; afilterprocessor’s conditions go indrop. - Contexts are per statement here. The Collector infers one context for a whole list of
statements, the most specific one its paths need. A list with a
span.statement and aspanevent.statement runs once for every span event there, so spans without events never see thespan.statement, a redaction included. Here each statement runs in its own context, so thespan.statement reaches every span. A list whose statements all share one context behaves the same in both. - Errors are always ignored, as with the processors’
error_mode: ignore. - The
transformprocessor’s metric-only functions, such asaggregate_on_attributes,convert_sum_to_gaugeandextract_count_metric, aren’t available. flatten,GetXML,InsertXMLandExtractGrokPatterns’pattern_definitionsaren’t available, and a few arguments are limited so that one rule can’t make a value far larger than it was: see function limits.
In the console
Open Observability > Datasets, a dataset, then the Transforms tab.
- Add rules starts a document. Each signal has a card with two editors, Statements and Drop, one rule per line. Lines are numbered from 1, and rules that don’t compile are marked on their lines when you save.
- The Enabled switch turns the rules on or off. Turning them off asks you to confirm; the rules stay saved.
- Save saves the whole document. If someone else saved the rules since you opened them, saving says so and shows theirs, and your edits aren’t saved.
- Without
obs.datasets.update, the tab shows the rules read-only.


Test on a card runs that signal’s rules, as edited and unsaved, on records you paste: an OTLP/JSON export request, prefilled with a sample you can edit. It shows how many records went in and were dropped and how many times a rule failed, then each record before and after, with dropped records struck through and the statements that failed on a record. For metrics, it warns when the rules merge series. Nothing is saved or stored, and the test runs whether or not the rules are enabled.


From the CLI and the API
ezgh obs datasets transforms get production --project web
ezgh obs datasets transforms get production -o json --project web > rules.json
ezgh obs datasets transforms test production -f rules.json --records sample-logs.json --signal logs --project web
ezgh obs datasets transforms set production -f rules.json --project webgetshows whether the rules run, the version, and each signal’s statements and drop conditions, numbered from 0 under the paths errors use (logs.statements.0). With-o jsonit prints the document with its version; edit the file and pass it totestandset.testruns one signal’s rules from a file on the records in--records, an OTLP/JSON export request, without saving anything. It shows how many records were dropped and where, the rules that failed on a record and, for metrics, series the rules merge.--output-recordswrites the transformed records to a file.setreplaces the whole document from a file (-f -reads standard input). The file can be a document or the output ofget -o json. If someone changed the rules since they were read, nothing changes andsetsays so;--forceoverwrites them anyway.
The dataset is given by environment, ID, dst_ slug or name. See
ezgh obs datasets transforms.
A dataset’s rules are a sub-resource of the dataset, in the project’s Observability API:
| Operation | Request |
|---|---|
| GetDatasetTransforms | GET /v1/organizations/<organization ID>/projects/<project ID>/obs/datasets/<dataset ID>/transforms |
| UpdateDatasetTransforms | PUT …/obs/datasets/<dataset ID>/transforms |
| TestDatasetTransforms | POST …/obs/datasets/<dataset ID>/transforms/test |
GETreturns{ "document": {…}, "version": 3, "updatedAt": "…" }with anETag. A dataset that never had rules returns{ "document": { "enabled": false }, "version": 0 }, with the dataset’s creation time asupdatedAt.PUTtakes a document and replaces the whole document;versiongoes up by one.If-Matchis optional: send the ETag fromGETso a change saved in the meantime answers412 precondition_failedinstead of being overwritten. It takes anIdempotency-Key.- A document outside the bounds, or an enabled one with a rule that doesn’t compile, is
400 invalid_request, with every problem inissuesas{ "path", "message" }.pathis dotted with numeric indices from 0, such aslogs.statements.3, andmessageis OTTL’s error. A document with"enabled": falseis checked for shape and bounds only, so rules that stopped compiling can always be turned off. PUTon a dataset being deleted is409 dataset_deleting.- The test takes
{ "signal": "logs", "document": {…}, "records": { "resourceLogs": […] } }, at most 1 MiB in all.documentis a whole document, checked asPUTchecks it, andrecordsis an OTLP/JSON export request of the signal, at most 256 KiB as sent. Onlysignal’s section runs, on every resource inrecordswhatever its environment. The answer isoutput(the transformed request, in the input’s order without what was dropped),dropped(locations),errors(locations with the failing rule’spathandmessage),collisions(metrics) andstats. A location is{ "resource", "scope", "record", "item" }, numbered from 0 in the input:recordis a log record, span or metric, anditema span event or data point.droppedanderrorshold at most their first 1,000 entries;statscounts every one.
Limits
| Limit | Value |
|---|---|
| Statements | 50 per signal |
| Drop conditions | 50 per signal |
| A statement or condition | 4 KiB, not blank |
| Document | 64 KiB |
| Test request | 1 MiB, rules and records together; the records at most 256 KiB |
| Functions | See function limits |
| Growth | The rules may add at most 1 MiB or twice the size of the records they run on, whichever is larger, to a request |
| Test answer | The first 1,000 dropped locations and the first 1,000 errors; stats counts them all |
Function limits
So that one statement can’t make a value far larger than it was, a rule that breaks one of these
doesn’t compile: PUT and the test answer 400 with an issue at its path.
- Not available:
flatten,GetXMLandInsertXML, whose output can grow far beyond their input, andExtractGrokPatterns’pattern_definitions. Write the expression in the grok pattern itself instead, as(?P<name>…). - Replacements: in
replace_pattern,replace_all_patterns,replace_matchandreplace_all_matches, the replacement andreplacement_formatare strings written in the rule, at most 256 bytes together. The replacement has at most 8 capture references ($1,${name}) and the format one%s.functionis one ofFNV,MD5,Murmur3Hash,Murmur3Hash128,SHA1,SHA256,SHA512,ToLowerCase,ToUpperCase,XXH128andXXH3. - Lists:
Concat,Formatandappendtake at most 16 values in a list. Any list or map written in a rule holds at most 16 fields of the record, such aslog.body. - Per-element text:
Concat’s delimiter is a string of at most 256 bytes written in the rule.ToKeyValueString’s delimiters andConvertTextToElementsXML’s element name are at most 256 bytes. Format: at most 16 verbs, whose widths and precisions add up to at most 256, and no*.- Captures:
ExtractPatternsandExtractGrokPatternstake a pattern written in the rule, with at most 16 named groups (and, for grok, patterns such as%{WORD:name}) written in it. - One enlarging function per rule: a statement or condition calls at most one of
Base64Encode,Concat,ConvertTextToElementsXML,Decode,ExtractGrokPatterns,ExtractPatterns,Format,Hex,StringandToKeyValueString. To combine them, store the first result in an attribute in one statement and use it in the next.
Access and Trails
| Action | Allows |
|---|---|
obs.datasets.get |
Viewing a dataset’s rules, and testing rules |
obs.datasets.update |
Changing a dataset’s rules |
ObsFullAccess includes both; ObsReadOnlyAccess and ReadOnlyAccess include
obs.datasets.get.
Changing the rules is recorded in Trails as UpdateDatasetTransforms, with
eventSource obs.ezghcloud.com. The event records whether the rules are enabled, each signal’s
number of statements and drop conditions, and the new version, never the rules themselves,
because their text can contain your data and the very values you redact. Viewing and testing rules
isn’t recorded.