SWIP-16 Support LLM-as-Judge on Top of GenAI Observability - #13943
SWIP-16 Support LLM-as-Judge on Top of GenAI Observability#13943peachisai wants to merge 15 commits into
Conversation
| return new StorageID() | ||
| .append(TRACE_ID, traceId) | ||
| .append(SERVICE_ID, serviceId) | ||
| .append(SERVICE_INSTANCE_ID, serviceInstanceId) | ||
| .append(SPAN_ID, spanId) | ||
| .append(SPAN_TYPE, spanType) | ||
| .append(TASK_NAME, taskName) | ||
| .append(EVALUATION_LEVEL, evaluationLevel) | ||
| .append(EVALUATION_TIME, evaluationTime); |
There was a problem hiding this comment.
This looks dangerous, could you ref to existing app log?
I feel we have a uuid kind of thing?
There was a problem hiding this comment.
This looks dangerous, could you ref to existing app log? I feel we have a uuid kind of thing?
Fixed
|
Thanks for the work here — the module structure, the PPM sampling and pairing the records with a MAL metric are all the right shape. I want to focus this round entirely on the query API, because 1. The UI cannot render the page SWIP-16 specifies
The house convention is the opposite — type Log {
serviceName: String
serviceId: ID
serviceInstanceName: String
serviceInstanceId: ID
endpointName: String
endpointId: ID
...
}Worse, this is not fixable later without a data migration: Please add 2. The chart → records drill-down is not expressibleThe whole reason to pair a record list with a metric is that a user clicks a dip in the score chart and gets the responses behind it. (Separately, SWIP-16 names the metric 3. The primary filters do not work on Elasticsearch, and the trace drill-down silently returns nothing on BanyanDB
In
On BanyanDB the drill-down fails a different way: query.and(eq(GenAIEvaluationRecord.SPAN_ID, (long) relatedTrace.getSpanId())); // L89That binds an int64 against a tag registered from a Suggested fix: drop 4. "The 20 worst-scoring responses" cannot be asked
We already have precedent for both halves — 5. The filter dropdowns cannot be populatedTask names and level bands are operator config in They are also reachable only through the generic Every other family that offers Two smaller notes while the API is open: Lastly — nothing in the tree executes |
# Conflicts: # docs/en/changes/changes.md
…' into Add-the-genai-evaluation-feature
Follow-up to #163, from reviewing the OAP implementation in apache/skywalking#13943. No OAP submodule pointer references #163 yet, so these changes are free of client impact. - Remove `tags` and `GenAIEvaluationRecordTag`. A tag condition exists to filter key-values the protocol cannot enumerate; this record persists no user-supplied attribute. `Log` returns `tags` and `LogQueryCondition` filters them, whereas `GenAIEvaluationRecord` has no `tags` field at all, so the condition filtered something the API never returns. - Add `GenAITraceRef` / `GenAITraceRefType`. GenAI evaluation accepts native, OTLP and Zipkin traces. Native span ids are a segment-local int index; OTLP and Zipkin ids are 16 hex characters. `spanIndex` is named for what it is - an index, not an identifier. Scoped to GenAI: `spanId: Int` elsewhere is correctly scoped to native addressing and is unchanged. - Split the value slots into `scoreValue` / `booleanValue` / `stringValue` so `valueType` genuinely discriminates. BOOLEAN previously shared `scoreValue`, so score-range filters spanned both types. - Drop `spanType`: `SpanEvaluationType` has one value, so there is nothing to query and nothing to display. - Document entity scope and layer on the id fields - provider is a service and model is its instance, both VIRTUAL_GENAI, while `serviceId` is a normal agent-detected service. Scores remain `Long` on the ppm scale; SkyWalking does not carry Float/Double in stored or transported values.
wu-sheng
left a comment
There was a problem hiding this comment.
Thanks for taking SWIP-16 this far — the pipeline shape is right, and keeping the judge call off the trace-analysis critical path is the correct core decision.
I've done a full pass over the storage structure, query logic, and the ingestion-to-judge pipeline. A lot below, so I've ordered it by what unblocks what. Everything is from reading the source, because the branch doesn't compile, so none of it has run on CI.
Heads-up: the query protocol changed
Reviewing this surfaced problems in the merged schema, so I raised and merged skywalking-query-protocol#164. The contract you're implementing against has moved — please bump the submodule pointer early, since several mismatches below only become visible once you do.
1. Blockers
Build is red.
AIEvaluationProvider.java:[27,60] package org.apache.skywalking.oap.meter.analyzer.v2.dsldebug does not exist
The package is …meter.analyzer.v2.dsl.debug. 225 of 230 checks fail behind this. See §2 — the cleanest fix removes the import entirely.
OAP doesn't boot out of the box. application.yml enables ai-evaluation by default, and prepare() → OpenAICompatibleProvider.validate() throws ModuleStartException when endpoint/api-key are blank — which is what ai-evaluation.yml ships. Every stock install and every existing e2e fails at startup. A feature that spends money per span should be opt-in: default the selector off, and construct no judge when unconfigured.
MAL rules and config never reach the distribution. ai-evaluation.yml and gen-ai-evaluation-rules/ aren't in apm-dist/src/main/assembly/binary.xml, so Rules.loadRules resolves inside the starter jar and Files.walk throws. The file whose header says "judge model and tasks are maintained here" also isn't in the tarball for an operator to edit.
MockCoreModuleProvider doesn't register GenAIEvaluationRecordQueryService. CoreModule.services() gained it, so profile-exporter throws ServiceNotProvidedException at boot. This is the case in CLAUDE.md tip #11 — check every extends CoreModuleProvider.
2. Module placement — please reconsider
ai-evaluation is a new top-level Maven module, sibling to ai-pipeline, while analyzer/gen-ai-analyzer already recognizes GenAI spans and resolves provider/model. The separate ModuleDefine is justified — evaluation should be disableable independently. The Maven placement isn't, and it's what produced the duplication:
GenAISemanticAttributesre-transcribesGenAITagKeys, with inconsistent constant names for identical values.GenAIContextResolverre-implementsGenAIMeterAnalyzer's provider resolution — and diverges from it. On the native path the existing analyzer doesprovider.name → matcher, while the new resolver inserts agen_ai.systemfallback. For Azure OpenAI (gen_ai.system=az.ai.openai, modelgpt-4o) the analyzer yieldsopenaiand the evaluator yieldsaz.ai.openai— traffic and cost land on one virtual GenAI service, the quality score on another.GenAIEvaluationAnalysisListenersits inagent-analyzer, not here, so the two halves of the pipeline are already split.
Suggested: keep the ModuleDefine, move the Maven module under analyzer/, move the native listener into it so the dependency points ai-evaluation → agent-analyzer (not the reverse), and lift the tag constants plus one resolveProvider(tags) into library-util/…/genai/ where GenAIModelMatcher already lives.
Related: the MAL rule shouldn't have its own catalog. gen-ai-evaluation-rules is registered in neither Catalog nor MalRuleEngine.CATALOGS, so it's the only MAL rule in the product that can't be hot-updated or debugged. It's one labeled metric from an in-process producer — exactly what meter-analyzer-config already holds (continuous-profiling.yaml, network-profiling.yaml). Move it there and dispatch through IMeterProcessService.converts() instead of loading rules and holding a private List<MetricConvert>. That also deletes the dsl.debug import causing the build failure, and the dist-packaging problem for the rules directory.
3. Data model
Worth settling before the rest, since query and dashboard sit on top.
Column naming. EVAL_NUMBER_VALUE = "evaNumberValue" and EVAL_STRING_VALUE = "evalStringValue" are the only camelCase column names in core/analysis/ — and one has a typo (eva vs eval; the constant is spelled correctly). Please use eval_number_value / eval_string_value, field evalNumberValue.
model_id doesn't identify the model. The GenAI entity model is already defined: provider is a service in Layer.VIRTUAL_GENAI, model is a service instance of it (GenAIProviderAccess / GenAIModelAccess). toEntityId(modelName) builds a ServiceID from a model name — matching nothing. Same model name under two providers collides, and toStoredModelId() exists only to translate the UI's real ServiceInstanceID into the synthetic form. Store the canonical ServiceInstanceID.
Span id. Integer.parseInt(context.getSpanId()) runs before the record is written. AIEvaluationSpanListener supplies a 16-char hex Zipkin id, so every OTLP/Zipkin evaluation is discarded after the judge call is billed — the exception is swallowed by AIEvaluationService.evaluate(). #164 introduces GenAITraceRef for this: a SKYWALKING_NATIVE | OTLP discriminator, with spanIndex: Int for native (it's a segment-local index, not an id) and spanId: String for OTLP/Zipkin. Storage becomes a ref_type column plus a String span-identifier column, and the parse disappears.
Score scale. getScoreValue() returns evaNumberValue / (double) SCORE_SCALE, but the schema declares scoreValue: Long at ppm — so every non-integral score fails scalar serialization and only 0.0 / 1.0 survive. Separately minScore/maxScore are Long ppm in the schema but Double in Java, and the DAOs then multiply by 1e6 again, so a UI sending 860000 produces a bound of 860000000000 and matches nothing. The write path is already right (toScoreValuePpm uses BigDecimal); keep Long end to end and stop converting on the read path.
storageOnly columns used as filters. value_type and segment_id are declared storageOnly = true — which Column's javadoc defines as "never used as a query condition" — yet both are filtered. On Elasticsearch that's query_shard_exception; on JDBC it silently works. value_type is the filter the schema documents as primary.
Small ones: task_name at length = 512 is longer than any shipped task name and pushes the column past the string-index threshold — 64 is plenty. reason and evalStringValue are 4096 but hold unbounded LLM text with no truncation on the write path. setServiceName skips NamingControl while the two adjacent lines apply it, so a service name over 70 chars gets an id the UI can never match.
4. Pipeline
Buffering and backpressure. new ThreadPoolExecutor(4, 4, 0, SECONDS, new ArrayBlockingQueue<>(100)) is hardcoded — no configuration, unnamed non-daemon threads, never shut down. Total admission is 104, and since each task makes a blocking HTTP call the sustained rate is 4 / judge_latency, well under 1 span/sec. Please use library-batch-queue with BufferStrategy.IF_POSSIBLE (returns false rather than constructing an exception), with bufferSize and consumer threads configurable. Note the default PartitionSelector.typeHash() collapses a single-element-type queue onto one partition — a custom selector is needed.
Drops must be counted, not logged. The rejection path logs warn with the exception, so overflow prints a stack trace per dropped span — the trace is identical every time and carries nothing. There's no telemetry in the module at all, so coverage can fall from 100% to 1% with no signal but log volume. That silently invalidates the score metric, because what survives is no longer the uniform sample the ppm policy computed. Please add MetricsCreator counters (adding TelemetryModule.NAME to requiredModules()), with reasons naming the operator-actionable condition — sampling / pipeline capacity / incomplete span — and adopt them in otel-rules/oap.yaml. Judge errors belong there too, split rejected / timeout / invalid_response: the first says fix credentials or quota, the last says the model is ignoring the output contract. Today both are the same IOException.
Validate before buffering. support() only checks that traceId and spanId are non-empty; the real gate, validLLMCallSpan(), runs on the worker thread — so non-judgeable spans consume a queue slot, a thread and a dedup key before being discarded. findStrategy() returning null already gives a free rejection path. And the gate itself is too narrow: gen_ai.operation.name == "chat" excludes text_completion and generate_content, which are inference calls carrying both message fields. The sufficient predicate is the next line — input and output messages present.
Dedup key. traceId + "-" + spanId omits segmentId. spanId is unique only within a segment, so two segments of one trace both start at 0 and the second evaluation is silently dropped. segmentId is already in the context.
Timestamps. evaluationTime = System.currentTimeMillis() at judge completion becomes both the record's time_bucket and the MAL sample timestamp, so queue depth directly displaces the score series from the traffic it describes. AIEvaluationContext.endTimeMillis is populated by both listeners and never read (as are startTimeMillis, serviceInstanceName and operationName). Stamp from the span.
Default sample rate is 100% (1000000), so every GenAI span triggers a paid judge call out of the box. The env var is also misspelled — SW_AI_EVALUTION_SAMPLE_RATE.
Parser is all-or-nothing. JsonParser.parseString(content).getAsJsonObject() is unguarded — a judge returning markdown-fenced JSON (common) throws — and one missing task key discards all four results after the call is billed. Please strip fences and validate per task, persisting what parsed.
Prompt construction. Input/output messages are concatenated with no truncation or size ceiling; max_tokens: 100000 caps only the response. And the judged model's own output is injected unescaped ahead of the task list, so text in a monitored application's response can restate the tasks or dictate the verdict — which is then stored as ground truth and aggregated into a dashboard metric. Please delimit and escape the untrusted region and put instructions after it.
No 429 / Retry-After handling. Any non-2xx becomes IOException, indistinguishable from a 400, and there's no retry — so a rate-limit burst discards every evaluation in flight after each was billed.
5. Configuration
Please align the judge config with Horizon's shipped schema (apps/bff/src/config/schema.ts), which already solved this vendor-neutrally: provider: openai-compatible | bedrock naming a transport rather than a vendor, base-url for OpenAI-shaped endpoints, optional region for Bedrock falling back to AWS_REGION, and model carrying the exact Bedrock/inference-profile id. Horizon deliberately omits temperature and token caps — "the gateway / provider / model owns them" — worth matching. Env prefix should be SW_* per project convention, and the api-key needs a redaction story.
On the two config files: the judge block is wiring, not content, so the split as drawn isn't justified — and prepare() discarding the module-bound config to hand-carry sampleRate shows the seam. Suggest application.yml takes selector, sampleRate, buffer size, consumer threads and the judge block, while ai-evaluation.yml keeps system-prompt, level and tasks — the content an operator authors, matching the alarm-settings.yml precedent.
One bug there: level.boolean uses unquoted true: / false: keys, which SnakeYAML parses as Boolean, while the loader looks them up as String. BOOLEAN tasks always fall back to undefined.
6. Tests
test/e2e-v2/cases/otlp-virtual-genai/ already emits GenAI spans over OTLP against BanyanDB — the natural host, and exactly the path the span-id bug breaks. Run it at 100% sample rate against a mock judge returning a fixed OpenAI-shaped choices[0].message.content. Two properties worth designing in: the stub must return exactly the configured task keys, which makes it a contract test for the prompt/parser pair; and a deterministic score so the assertion pins 0.86 → 860000 in both the record and the metric. With a native-path case too, e2e would cover five of the eight blockers above.
Happy to go through any of these in more detail. I'd suggest sequencing as: get the build green, then the data model (naming, trace ref, score scale, model_id), then module placement and the MAL rule move — the pipeline and config items are largely independent of each other once those land.
|
Follow-up on the buffering point in my earlier review — #13979 added Suggested replacement for
|
CHANGESlog.