Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/victoriatraces/changelog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ The following `tip` changes can be tested by building VictoriaTraces components

## tip

* BUGFIX: [Single-node VictoriaTraces](https://docs.victoriametrics.com/victoriatraces/) and vtselect in [VictoriaTraces cluster](https://docs.victoriametrics.com/victoriatraces/cluster/): support unscoped attributes in TraceQL queries for the Tempo search API. Previously such queries always returned empty results. See [this issue #198](https://github.com/VictoriaMetrics/VictoriaTraces/issues/198).

## [v0.12.0](https://github.com/VictoriaMetrics/VictoriaTraces/releases/tag/v0.12.0)

Released at 2026-09-29
Expand Down
9 changes: 9 additions & 0 deletions lib/traceql/filter_general.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ func (fc *filterCommon) String() string {
return otelpb.ParentSpanIDField + `:=""`
}

// Unscoped attribute `.foo` reads the span attribute if the span has it, and the resource attribute otherwise,
// `.service.name` is mapped to the resource stream field below.
if attr, ok := strings.CutPrefix(fc.fieldName, "."); ok && fc.fieldName != ".service.name" {

@cubic-dev-ai cubic-dev-ai Bot Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Dotted intrinsic names (.name, .kind, .status, .duration) now fall into this unscoped-attribute branch and are resolved as span_attr:/resource_attr: attribute lookups, while the same keywords without the dot resolve to the span name stream field (name), status_code, kind, or the duration field. .status = "error" will never match the status intrinsic, and a span whose literal attribute is named status would now match where it previously didn't. Extend the special-case beyond .service.name (or remap these dotted forms to the intrinsic fields) so unscoped intrinsics keep intrinsic semantics.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At lib/traceql/filter_general.go, line 116:

<comment>Dotted intrinsic names (`.name`, `.kind`, `.status`, `.duration`) now fall into this unscoped-attribute branch and are resolved as `span_attr:`/`resource_attr:` attribute lookups, while the same keywords without the dot resolve to the span name stream field (`name`), `status_code`, `kind`, or the `duration` field. `.status = "error"` will never match the status intrinsic, and a span whose literal attribute is named `status` would now match where it previously didn't. Extend the special-case beyond `.service.name` (or remap these dotted forms to the intrinsic fields) so unscoped intrinsics keep intrinsic semantics.</comment>

<file context>
@@ -111,6 +111,15 @@ func (fc *filterCommon) String() string {
 
+	// Unscoped attribute `.foo` reads the span attribute if the span has it, and the resource attribute otherwise,
+	// `.service.name` is mapped to the resource stream field below.
+	if attr, ok := strings.CutPrefix(fc.fieldName, "."); ok && fc.fieldName != ".service.name" {
+		spanFilter := filterCommon{fieldName: "span." + attr, op: fc.op, value: fc.value}
+		resourceFilter := filterCommon{fieldName: "resource." + attr, op: fc.op, value: fc.value}
</file context>
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Tempo, a leading . means an attribute rather than an intrinsic. I checked this against Tempo 3.1.0: {.status = "error"} matches an attribute named status, while bare {status = error} matches the built-in status field, which is the same behavior we have here

spanFilter := filterCommon{fieldName: "span." + attr, op: fc.op, value: fc.value}
resourceFilter := filterCommon{fieldName: "resource." + attr, op: fc.op, value: fc.value}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tempo also looks at the instrumentation scope after the resource (protospan.go). So a span that has istio.cluster_id = "c1" only on its scope is missed by =. It also shows up for = nil and != "c1".

The issue only asks for span and resource, so this is fine as a follow-up. If you want to add it here, a third branch on scope_attr: should work, since {instrumentation.istio.cluster_id = "c1"} already returns that span.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wrong here, sorry. I ran Tempo 3.1.0 with the same spans, and {.istio.cluster_id = "c1"} returns only the span and resource matches. The scope-only span comes back only for {instrumentation.istio.cluster_id = "c1"}, so the PR already matches Tempo and you can ignore this note.

spanField := quoteFieldNameIfNeeded(otelpb.SpanAttrPrefixField + attr)
return "(" + spanField + ":* and " + spanFilter.String() + " or " + spanField + `:"" and ` + resourceFilter.String() + ")"
}

// TraceQL's `attr = nil` / `attr != nil` map to LogsQL's empty-value and
// any-value filters respectively — the canonical forms per
// https://docs.victoriametrics.com/victorialogs/logsql/#empty-value-filter
Expand Down
6 changes: 6 additions & 0 deletions lib/traceql/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ func TestParseQuery(t *testing.T) {

// Regex match operator `=~` must become LogsQL `~` (not `=~`).
f(`{resource.host.name =~ "kimi-k2-a.*"}`, `"resource_attr:host.name":~"kimi-k2-a.*"`)

// unscoped attributes read the span attribute if present, and the resource attribute otherwise.
f(`{.istio.cluster_id = "c1"}`, `("span_attr:istio.cluster_id":* and "span_attr:istio.cluster_id":=c1 or "span_attr:istio.cluster_id":"" and "resource_attr:istio.cluster_id":=c1)`)
f(`{.service.name = "x" && .istio.cluster_id = "c1"}`, `{"resource_attr:service.name"=x} and ("span_attr:istio.cluster_id":* and "span_attr:istio.cluster_id":=c1 or "span_attr:istio.cluster_id":"" and "resource_attr:istio.cluster_id":=c1)`)
f(`{.foo !~ "a.*"}`, `("span_attr:foo":* and "span_attr:foo":!~"a.*" or "span_attr:foo":"" and "resource_attr:foo":!~"a.*")`)
f(`{.foo != nil}`, `("span_attr:foo":* and "span_attr:foo":* or "span_attr:foo":"" and "resource_attr:foo":*)`)
}

// TestParseQueryInvalid asserts that malformed inputs return an error
Expand Down