diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md index 1963a424811c..1841774216b7 100644 --- a/docs/en/changes/changes.md +++ b/docs/en/changes/changes.md @@ -28,7 +28,7 @@ * Bring the TraceQL module closer to the Tempo API. Every datasource lists only the spans that matched a search in each span set, capped at `spss`, fills `serviceStats`, filters `span.http.status_code`, honours `scope` and the per-scope `limit` on the tag endpoints, whose `intrinsic` scope lists what each datasource can filter, accepts Tempo's unscoped `.key` tag names on the tag-value endpoints, and answers a tag-name or tag-value lookup filtered by `q` from a sample of the newest 50 matching traces, so Grafana's dependent dropdowns narrow down; the `status` and `kind` dropdowns always list their full enum. The `/otlp` datasource also accepts Tempo's `span:name`, `span:kind`, `span:status` and `span:duration` spellings. `/api/metrics/query_range` and `/api/metrics/query` answer `501 Not Implemented` with an error body and accept Tempo's `q` parameter, instead of `200` with a string Grafana cannot parse. * Refuse unsupported TraceQL with `400` instead of dropping the predicate and answering with unfiltered traces: syntax errors (`||` inside a spanset, regex, an attribute without a scope or leading dot), `!=` and other unsupported operators, negation, attribute-existence checks, multiple spansets, unknown intrinsics and values, and conditions a datasource cannot filter (`kind` on Zipkin and SkyWalking, `resource.instance` on Zipkin, `resource.remote.service` and `status = unset` on SkyWalking, `resource.instance` or `name` without a service on SkyWalking). The deprecated `tags` search parameter is parsed as logfmt on every datasource and goes through the same mapping and refusals. `kind = server` and `status = error` accept the bare keyword as Tempo does. Every search-result span carries a `status` attribute (`error`, `ok`, `unset`) next to `service.name` and `span.kind`, so a trace list can show failures. A trace the storage matched but none of whose spans satisfies every condition is left out instead of listed with every span, and the `/otlp` datasource compares names the way the receiver indexed them and keeps the `resource.` and `span.` scopes apart in its tag index, so `span.env` no longer matches a resource attribute. `duration >` and `<` are strict at microsecond precision. (#14093) * Support the OpenTelemetry Collector `hostmetrics` receiver as an alternative source for Linux and Windows host monitoring. `vm.yaml` and `windows.yaml` map it to the same `meter_vm_*` / `meter_win_*` metrics as node-exporter and windows_exporter, whose metrics keep their meaning, and add metrics only hostmetrics provides: CPU core count and normalized CPU usage, plus CPU load, file-system usage, system handle count and pagefile usage on Windows. node-exporter metrics without a matching hostmetrics source (`tcp_alloc`, `sockets_used`, `udp_inuse`, `filefd_allocated`) stay node-exporter only. The new `process-hostmetrics-linux` and `process-hostmetrics-windows` rules, enabled by default, report each process name as an instance of its host (`mp_process_linux_*` / `mp_process_windows_*`: process count, threads, CPU, resident memory, open handles and oldest process uptime). Reference Collector configurations for both systems are under `docs/en/setup/backend/`; they set the `job_name` and normalized `process_name` labels the rules route and group on, and sum same-named processes before export. -* AI agent conversations adopt AI Sessionizer `130601c`. Calls to MCP servers: the `execution` kind the Sessionizer's Claude Code plugin writes, `streams//execution--.sd`, is stored like any other file; the document lists every `execution/1` record under `tool_executions`, joined to its step by tool-use id and kept once by its own id, a tool step names its records under `executions`, and a call to an MCP server carries `mcp_server` and `mcp_tool` in its `attrs`. The new `otel-rules/ai-agent/mcp_endpoint.yaml` gives one endpoint per MCP server and tool, `/`, under the agent's service, with `meter_ai_agent_mcp_calls`, `meter_ai_agent_mcp_calls_by_outcome` and `meter_ai_agent_mcp_duration` from the Sessionizer's `agent.mcp.calls` and `agent.mcp.duration`. The token rules read `agent.token.usage`, the Sessionizer's name for the metric. The document follows the Sessionizer's: an `llm.call` takes its provider bodies from the round's `provider_bodies` attribute and the session its count from `provider_bodies_landed`, and a round whose bodies do not read or point past its range is refused; talks are in the order they began across streams; only a `child` stream's talk is a child's, not an `auxiliary` one's; a stream's `opened_by`, the relations and a step's edges are in the order they happened, by record position inside one stream or workflow run and by time across them, never by id; change and execution records of one instant are in the order they were read; a record is read by the fields its format lists, and one of another shape is skipped; a round with a frame field of another type, or a negative sequence, row or round number, does not read; record times compare as instants. +* AI agent conversations adopt AI Sessionizer `130601c`. Calls to MCP servers: the `execution` kind the Sessionizer's Claude Code plugin writes, `streams//execution--.sd`, is stored like any other file; the document lists every `execution/1` record under `tool_executions`, joined to its step by tool-use id and kept once by its own id, a tool step names its records under `executions`, and a call to an MCP server carries `mcp_server` and `mcp_tool` in its `attrs`. The new `otel-rules/ai-agent/mcp_endpoint.yaml` gives one endpoint per MCP server and tool, `/`, under the agent's service, with `meter_ai_agent_mcp_calls`, `meter_ai_agent_mcp_calls_by_outcome` and `meter_ai_agent_mcp_duration` from the Sessionizer's `agent.mcp.calls` and `agent.mcp.duration`. The token rules read `agent.token.usage`, the Sessionizer's name for the metric. The document follows the Sessionizer's: an `llm.call` takes its provider bodies from the round's `provider_bodies` attribute and the session its count from `provider_bodies_landed`, and a round whose bodies do not read or point past its range is refused; talks are in the order they began across streams; only a `child` stream's talk is a child's, not an `auxiliary` one's; a stream's `opened_by`, the relations and a step's edges are in the order they happened, by record position inside one stream or workflow run and by time across them, never by id; change and execution records of one instant are in the order they were read; a record is read by the fields its format lists, and one of another shape is skipped; a round with a frame field of another type, or a negative sequence, row or round number, does not read; a record time is RFC 3339 as Session Data defines it, to the second, and anything else is no time; record times compare as instants. #### UI * Add a Virtual GenAI evaluation-record page and evaluation-score chart in Horizon UI, so operators can inspect evaluation result, level, reason, judge model, timestamp, trace linkage, and the `gen_ai_model_evaluation_score_ppm` trend for evaluated records. diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java index 456c0cfb0297..b366ffcff062 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java +++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java @@ -21,8 +21,12 @@ import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.time.chrono.IsoChronology; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.time.temporal.ChronoField; import javax.annotation.Nullable; import org.apache.skywalking.oap.server.library.util.StringUtil; @@ -37,6 +41,31 @@ public final class Times { private static final DateTimeFormatter FILE_STAMP = DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss.nnnnnnnnn'Z'").withZone(ZoneOffset.UTC); + /** + * A time as Session Data defines it: a four-digit year, the letter T, the time of day to the second with a + * fraction of up to nine digits or none, and Z or an offset such as +08:00. java.time's ISO reader also takes a + * time without seconds and years of other widths, which the format does not. + */ + private static final DateTimeFormatter RFC_3339 = new DateTimeFormatterBuilder() + .appendValue(ChronoField.YEAR, 4) + .appendLiteral('-') + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .appendLiteral('-') + .appendValue(ChronoField.DAY_OF_MONTH, 2) + .appendLiteral('T') + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendLiteral(':') + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .optionalStart() + .appendFraction(ChronoField.NANO_OF_SECOND, 1, 9, true) + .optionalEnd() + .appendOffset("+HH:MM", "Z") + .toFormatter() + .withResolverStyle(ResolverStyle.STRICT) + .withChronology(IsoChronology.INSTANCE); + private Times() { } @@ -88,16 +117,14 @@ public static String fileStamp(@Nullable final String rfc3339) { return t == null ? null : FILE_STAMP.format(t); } - /** RFC 3339, with Z or an offset, and a year of four digits, as RFC 3339 has it. */ + /** A time as {@link #RFC_3339} reads it; null when it is not one. */ @Nullable private static Instant instant(@Nullable final String rfc3339) { if (StringUtil.isEmpty(rfc3339)) { return null; } try { - final OffsetDateTime t = OffsetDateTime.parse(rfc3339, DateTimeFormatter.ISO_OFFSET_DATE_TIME); - // java.time also reads years past 9999 and before 0; a moment that far out overflows unix milliseconds - return t.getYear() >= 0 && t.getYear() <= 9999 ? t.toInstant() : null; + return OffsetDateTime.parse(rfc3339, RFC_3339).toInstant(); } catch (final DateTimeParseException e) { return null; } diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/SessionFormatsTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/SessionFormatsTest.java index 7692e17dfdfb..44095c767108 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/SessionFormatsTest.java +++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/SessionFormatsTest.java @@ -287,6 +287,24 @@ private static SessionFlowRound.Node call(final String round) { .stream().filter(n -> "call/s2-call-fdae022ac306".equals(n.getId())).findFirst().orElseThrow(); } + /** + * A time is what Session Data defines: a four-digit year, the letter T, the time of day to the second with a + * fraction of up to nine digits or none, and Z or an offset with a colon. Anything else is no time, which reads as + * 0, the way the Sessionizer reads it: a time without seconds is not one, though java.time's ISO reader takes it. + */ + @Test + public void onlyATimeTheFormatDefinesIsATime() { + assertEquals(1767225601000L, Times.millis("2026-01-01T00:00:01Z")); + assertEquals(1767225601500L, Times.millis("2026-01-01T08:00:01.5+08:00")); + assertEquals(1767225601123L, Times.millis("2026-01-01T00:00:01.123456789Z")); + for (final String notATime : new String[] { + "2026-01-01T00:00Z", "2026-01-01T00:00+08:00", "12026-01-01T00:00:01Z", "2026-01-01T00:00:01+0800", + "2026-01-01t00:00:01Z", "2026-01-01T24:00:00Z", "2026-01-01T00:00:01.1234567890Z", "2026-02-30T00:00:01Z", + }) { + assertEquals(0L, Times.millis(notATime), notATime); + } + } + /** * Record times are RFC 3339, with Z or an offset, and compare as the instants they name, whatever the length of * their fractions or the offset they are written in. A time that does not parse sorts after every one that does,