Skip to content

Evaluation

Six detectors read the prompt and the response of a model call and write what they find on the span as evaluation.* attributes and into genai.evaluation.* metrics. They are ports of the reference implementation's rule-based detectors, checked result for result against its vectors (contract/eval_vectors.json, 194 cases).

Everything is off by default.

Detector Switch What it is
PII GENAI_ENABLE_PII_DETECTION, GENAI_PII_MODE = detect | redact | block 10 regular expressions: email, phone, card, US SSN, IP address, Aadhaar, PAN, UPI, Indian phone, IFSC. No names, no addresses
Prompt injection GENAI_ENABLE_PROMPT_INJECTION_DETECTION patterns in six groups
Bias GENAI_ENABLE_BIAS_DETECTION patterns and keywords in eight groups
Restricted topics GENAI_ENABLE_RESTRICTED_TOPICS patterns and keywords in nine topics
Hallucination GENAI_ENABLE_HALLUCINATION_DETECTION heuristics over the response: claims without citations, hedging, contradiction of the prompt
Toxicity GENAI_ENABLE_TOXICITY_DETECTION no rules - see below

Thresholds and *_BLOCK_ON_DETECTION are in configuration.md.

These are pattern detectors. They are cheap and run in-process with no model; they are also crude, and their false positives are the reference's ("She is a doctor." is gender bias; "harmless" contains harm). The platform's quality policies are the judge of record. Treat these as a first signal on the span.

What is read, and what is recorded

On a call made through an instrumented client the detectors read the whole prompt (every message) and the response, whether or not content capture is on. The text is read and not recorded: with content capture off, no prompt or response text is on the span. Two attributes do carry text, as in the reference: evaluation.pii.*.redacted (the text with the PII replaced, in redact mode) and the matched fragments in *_patterns and unsupported_claims. Turn the detector off if that is not acceptable.

On a span made by other instrumentation the prompt and response are taken from its attributes or events (gen_ai.prompt, llm.input_messages, input.value, ...). A span with neither is left alone.

Streamed calls: the prompt is evaluated; the response is not, because the instrumentation does not keep the streamed text.

Blocking

GENAI_PII_MODE=block and the *_BLOCK_ON_DETECTION switches stop a request before it is sent, on calls made through an instrumented client: the call throws PolicyViolationError, the provider is never contacted, and the span has status ERROR with evaluation.blocked = true and evaluation.blocked_reasons.

import { PolicyViolationError } from '@traceverse/genai-otel-instrument-node';

try {
  await client.chat.completions.create({ model, messages });
} catch (error) {
  if (error instanceof PolicyViolationError) {
    // error.policy: the first policy that triggered; error.details.reasons: all of them
  }
}

The error is thrown synchronously by the client method, not as a rejected promise; await handles both.

Only the prompt can be blocked. A response that trips a detector has already been produced and returned; the span is annotated (evaluation.<detector>.response.blocked), nothing is withheld from the application. On spans of other instrumentation blocking is annotation only.

Toxicity

The reference measures toxicity with a model (Detoxify) or a service (Perspective). It has no rules for it, and none are invented here. Until the application supplies a scorer, enabling toxicity detection writes nothing - no attribute and no metric. The reference writes detected = false in that situation; a "false" that nothing measured is not written here.

An on-prem judge: GENAI_TOXICITY_JUDGE_URL

Set GENAI_TOXICITY_JUDGE_URL to an http(s) endpoint the operator runs, and toxicity is scored there (contract/SDK_ADDITIONS.md section 12; the same protocol in every SDK). It is posted

{"text": "You are an idiot.", "categories": ["identity_attack", "insult", "profanity", "severe_toxicity", "threat", "toxicity"]}

and answers {"scores": {"toxicity": 0.91, ...}}, or the scores as a flat object as a Detoxify server does - Detoxify's obscene is read as profanity. A judge that fails, times out (the time a span waits for a score, 5 s unless set) or answers with no score measured nothing: no toxicity attribute is written for that text, never a 0. The request is this SDK's own and is never traced or counted as the application's traffic, even with GENAI_ENABLE_HTTP_INSTRUMENTATION on. The endpoint is the operator's, so it stays allowed with GENAI_AIR_GAPPED, which forbids downloading models and assets at run time. A scorer given in code (below) wins over the URL.

A local model: GENAI_TOXICITY_ONNX_MODEL

With the optional add-on genai-otel-instrument-node-onnx installed next to this package, GENAI_TOXICITY_ONNX_MODEL names a directory holding a Detoxify-style ONNX classifier (model.onnx, vocab.txt, optionally config.json), scored in-process by ONNX Runtime. This package never depends on ONNX Runtime: the add-on is imported on the first score, only when the variable is set. Not installed, or a model it cannot load: one warning, and toxicity is not measured. A local file is not a download, so it works air-gapped. See the onnx/README.md of the SDK package. Node only.

Precedence: a scorer given in code, then GENAI_TOXICITY_JUDGE_URL, then GENAI_TOXICITY_ONNX_MODEL.

A scorer in code

A scorer is a function from text to a score between 0 and 1 per category (toxicity, severe_toxicity, identity_attack, insult, profanity, threat). Give it before instrument():

import { instrument, setToxicityScorer } from '@traceverse/genai-otel-instrument-node';

setToxicityScorer(async (text, categories) => {
  // An on-prem endpoint. The mapping from its categories to these is yours to state.
  const response = await fetch('http://litellm.internal:4000/v1/moderations', {
    method: 'POST',
    headers: { 'content-type': 'application/json', authorization: `Bearer ${process.env.LITELLM_KEY}` },
    body: JSON.stringify({ model: 'moderation', input: text }),
  });
  const scores = (await response.json()).results[0].category_scores;
  return { toxicity: scores.harassment, threat: scores['harassment/threatening'], identity_attack: scores.hate };
}, { timeoutSeconds: 3 });

instrument();

Any other backend works the same way, inside the function.

Scorer answers at once Works on every span
Scorer answers with a promise Works on calls made through an instrumented client: the span is held until the scorer answers or the timeout passes (5 s unless set), and still ends with the time the call ended. flushTelemetry() waits for such spans. On spans of other instrumentation nothing is written
Scorer throws, rejects or times out No toxicity attribute for that text; the call and the span are unaffected
Blocking on toxicity before the call Only with a scorer that answers at once. A request cannot be held for a promise without changing what the client method returns

A scorer that calls outside the organisation is the application's decision and its responsibility: this package makes no such call itself. GENAI_TOXICITY_USE_PERSPECTIVE_API is read (and forced off when GENAI_AIR_GAPPED is set or GENAI_ALLOW_EXTERNAL_EGRESS is not), but no Perspective client ships.

Using the detectors directly

import { PIIDetector, PromptInjectionDetector } from '@traceverse/genai-otel-instrument-node';

const pii = new PIIDetector({ enabled: true, mode: 'redact' });
pii.detect('Mail jane.doe@example.com').redactedText; // 'Mail ********************'

They are plain classes with no dependency on the tracing setup, and are exported from the browser and React Native entries too. Offsets in PII results are Unicode code points, as in the reference.

Differences from the reference

  • Toxicity without a scorer writes nothing (above).
  • One code path: the span processor evaluates every span while it can still be written. The reference evaluates inside its instrumentors and again in a processor.
  • A span with no prompt and no response is not marked evaluation.completed.
  • The detectors' metrics are created on first use, so a detector that never fires leaves no empty series.
  • Not evaluated in the browser and React Native entries' instrument(): the detectors are exported there, the automatic span evaluation is not wired.