Spring AI · Metrikalar və sorğuların izlənməsi · Dərs 3
AI sorğularının icra yolunun izlənməsi
Spring Boot Actuator və Micrometer ilə Spring AI tətbiqinin metrikalarını müşahidə et və sorğuların keçdiyi mərhələləri izlə.
AI sorğularının icra yolunun izlənməsi
1. Bir əməliyyatı alt əməliyyatları ilə izləyin
Metrikalar Study Coach-un yavaşladığını göstərir; trace isə konkret sorğunun daxilində müşahidə olunan işin yerini tapmağa kömək edir. Trace span-ları trace ID ilə qruplaşdırır. Hər span öz span ID-si, başlanğıcı, sonu, atributları və mümkün hadisələri olan əməliyyatı təmsil edir. Alt span valideyn span-ın ID-sinə istinad edir.
Bizim sinxron icra yolumuzda iki span var: study.reply daxilində study.model yerləşir. Hər ikisinin trace ID-si eynidir. Model span-ı Spring AI adapterinin əməliyyatını əhatə edir. Provayderin daxili icrasını göstərmir. Bu laboratoriya HTTP client span-ı yaratmır və trace başlıqlarını uzaq xidmətə ötürmür.
Actuator və metrikalar dərslərinin bütün faylları ilə eyni layihəni davam etdirin. Micrometer Tracing-in OpenTelemetry körpüsünü yaddaşda saxlayan exporter ilə istifadə edirik. POM test asılılıqlarını artıq təmin edir. Collector URL-si, bulud hesabı və ya API sirri lazım deyil.
2. Seçmə qaydası aydın olan test tracer-i yaradın
Bu köməkçini test mənbələrində saxlayın. SimpleSpanProcessor tamamlanan span-ları sinxron şəkildə yaddaşa ixrac edir, buna görə yoxlamaların nəticəsi sabitdir. Köməkçi hər testdən sonra SDK və metrika registry-sini bağlayır. Qlobal SDK qeydiyyatdan keçirmir.
src/test/java/academy/study/observability/TraceLab.java
package academy.study.observability;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.tracing.handler.DefaultTracingObservationHandler;
import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext;
import io.micrometer.tracing.otel.bridge.OtelTracer;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
import io.opentelemetry.sdk.trace.samplers.Sampler;
final class TraceLab implements AutoCloseable {
final SimpleMeterRegistry meters = new SimpleMeterRegistry();
final InMemorySpanExporter exporter = InMemorySpanExporter.create();
final OpenTelemetrySdk sdk;
final OtelTracer tracer;
final ObservationRegistry registry;
TraceLab(boolean sample) {
var provider = SdkTracerProvider.builder()
.setSampler(sample ? Sampler.alwaysOn() : Sampler.alwaysOff())
.addSpanProcessor(SimpleSpanProcessor.create(exporter)).build();
sdk = OpenTelemetrySdk.builder().setTracerProvider(provider).build();
tracer = new OtelTracer(sdk.getTracer("study-coach"),
new OtelCurrentTraceContext(), event -> {});
registry = StudyObservations.local(meters);
registry.observationConfig()
.observationHandler(new DefaultTracingObservationHandler(tracer));
}
@Override public void close() {
sdk.close();
meters.close();
}
}Tracing handler xətanı təmizləyən handler-dən sonra əlavə olunur. Eyni observation həyat dövrü artıq həm metrika ölçmələri, həm də span-lar yaradır. Observation scope cari span-ı daxildəki sinxron iş üçün əlçatan edir. Əllə yaradılmış yeni thread bütün observation kontekstini avtomatik miras almır. Asinxron kod üçün uyğun kontekst ötürülməsi və ayrıca testlər lazımdır.
sample=true yalnız bu lokal testdə bütün span-ları qeyd edir. Boot-un avtomatik konfiqurasiya edilmiş tracing üçün standart ehtimalı 100% deyil, 10%-dir. Bizim köməkçi həmin avtomatik konfiqurasiyadan istifadə etmir və sampler-i özü seçir.
3. Prompt məzmununu ixrac olunan məlumatdan kənarda saxlayın
Spring AI 1.1.8-də istəyə bağlı spring.ai.chat.observations.log-prompt, log-completion və include-error-logging parametrləri var. Hamısının standart qiyməti false-dur. Onlar chat observation avtomatik konfiqurasiyasına aiddir. Layihəmiz modeli əllə yaradır və əlavə məzmun loglama handler-lərini quraşdırmır. Avtomatik konfiqurasiya parametrini yazmaq əllə qeydiyyatdan keçirilən handler-lərin yoxlanmasını əvəz etmir.
Aşağı kardinalıqlı observation qiymətləri adətən həm metrikalara, həm də izlərə çatır. Yüksək kardinalıqlı qiymətlər isə adətən yalnız izlərə çatır. Buna görə tələbə ID-sini yüksək kardinalıqlı qiymətə yazmaq onu məxfi etmir. SafeChatConvention yüksək kardinalıqlı qiymət çıxarmır və sabit kontekst adından istifadə edir. Provayderin idarə etdiyi model adlarını da təsdiqlənmiş profil etiketi ilə əvəz edir.
Xətalara da eyni diqqət lazımdır. Provayderin xəta gövdəsi və ya JSON dekodlaşdırma exception-u giriş və cavab parçaları daşıya bilər. SafeErrors tracing handler xətanı qeyd etməzdən əvvəl observation xətasını əvəz edir. Xaricdən çağırılan fasadımız isə orijinal səbəbi saxlamadan yeni, sabit exception atır. Bu seçim məxfilik naminə diaqnostik təfərrüatı azaldır. Testlər yalnız görünən span adlarını deyil, həqiqətən ixrac olunan span hadisələrini yoxlayır.
Əvvəlki dərslərin hər iki log konfiqurasiyasını saxlayın. Adapterin boş choices xəbərdarlığı observation məzmununun loglanmasından ayrıdır. Nümunədən kənarda qalan sərhədlər də var: HTTP məzmun logları, tətbiqin digər logları, üçüncü tərəf handler-ləri, baggage, collector emalı və saxlanma müddəti. Prompt və cavab obyektləri proses daxilində model observation kontekstində qalır. Sonradan quraşdırılan handler onları oxuya bilər. Hər yeni telemetriya istehlakçısını yoxlayın.
4. Əlaqələri, xətaları və scope-un bağlanmasını yoxlayın
Aşağıdakı yeddi testi saxlayın. Onlar mövcud ModelStub, ObservedStudyModel, StudyReplies və metrika konfiqurasiyasından istifadə edir. Süni marker məzmuna və metadataya yerləşdirilir, sonra tamamlanmış span məlumatında axtarılır. Boş choices testi adapter logger-inə müvəqqəti log appender də qoşur.
src/test/java/academy/study/observability/TracingTest.java
package academy.study.observability;
import io.micrometer.observation.Observation;
import io.opentelemetry.sdk.trace.data.SpanData;
import io.opentelemetry.api.trace.StatusCode;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class TracingTest {
@Test void modelSpanIsAChildAndPrivateValuesAreAbsent() throws Exception {
try (var lab = new TraceLab(true); var stub = new ModelStub()) {
var model = new ObservedStudyModel(stub.uri(), lab.registry);
new StudyReplies(lab.registry).run(StudyReplies.Language.EN,
() -> model.answer(ModelStub.PRIVATE));
var spans = lab.exporter.getFinishedSpanItems();
assertThat(spans).hasSize(2);
var root = spans.stream().filter(s -> s.getName().equals("study.reply")).findFirst().orElseThrow();
var child = spans.stream().filter(s -> s.getName().equals("study.model")).findFirst().orElseThrow();
assertThat(child.getTraceId()).isEqualTo(root.getTraceId());
assertThat(child.getParentSpanId()).isEqualTo(root.getSpanId());
assertThat(root.getParentSpanContext().isValid()).isFalse();
assertThat(spans.toString()).doesNotContain(ModelStub.PRIVATE);
assertThat(lab.registry.getCurrentObservation()).isNull();
assertThat(lab.tracer.currentSpan()).isNull();
}
}
@Test void errorExportContainsNoOriginalMessageOrCause() {
try (var lab = new TraceLab(true)) {
var observation = Observation.start("study.failure", lab.registry);
observation.error(new IllegalArgumentException(ModelStub.PRIVATE));
observation.stop();
SpanData span = lab.exporter.getFinishedSpanItems().getFirst();
assertThat(span.getStatus().getStatusCode()).isEqualTo(StatusCode.ERROR);
assertThat(span.getEvents()).isNotEmpty();
assertThat(span.toString()).contains("Study operation failed").doesNotContain(ModelStub.PRIVATE);
}
}
@Test void rejectedProviderCallHasTwoFailedSpansAndNoBodyLeak() throws Exception {
try (var lab = new TraceLab(true); var stub = new ModelStub()) {
stub.status = 500;
stub.body = ModelStub.PRIVATE;
var model = new ObservedStudyModel(stub.uri(), lab.registry);
assertThatThrownBy(() -> new StudyReplies(lab.registry).run(StudyReplies.Language.EN,
() -> model.answer(ModelStub.PRIVATE))).isInstanceOf(StudyObservations.StudyFailure.class);
assertThat(lab.exporter.getFinishedSpanItems()).hasSize(2)
.allSatisfy(s -> assertThat(s.getStatus().getStatusCode()).isEqualTo(StatusCode.ERROR));
assertThat(lab.exporter.getFinishedSpanItems().toString()).doesNotContain(ModelStub.PRIVATE);
assertThat(stub.calls).hasValue(1);
}
}
@Test void malformedProviderResponseDoesNotLeakToSpans() throws Exception {
try (var lab = new TraceLab(true); var stub = new ModelStub()) {
stub.body = "{invalid:" + ModelStub.PRIVATE;
var model = new ObservedStudyModel(stub.uri(), lab.registry);
assertThatThrownBy(() -> new StudyReplies(lab.registry).run(StudyReplies.Language.AZ,
() -> model.answer(ModelStub.PRIVATE))).isInstanceOf(StudyObservations.StudyFailure.class);
assertThat(lab.exporter.getFinishedSpanItems()).hasSize(2);
assertThat(lab.exporter.getFinishedSpanItems().toString()).doesNotContain(ModelStub.PRIVATE);
assertThat(lab.tracer.currentSpan()).isNull();
}
}
@Test void emptyChoicesAreRejectedWithoutPromptWarnings() throws Exception {
var logger = (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory
.getLogger(org.springframework.ai.openai.OpenAiChatModel.class);
var events = new ch.qos.logback.core.read.ListAppender<ch.qos.logback.classic.spi.ILoggingEvent>();
events.start();
logger.addAppender(events);
try (var lab = new TraceLab(true); var stub = new ModelStub()) {
stub.body = "{\"id\":\"local\",\"model\":\"local\",\"choices\":[]}";
var model = new ObservedStudyModel(stub.uri(), lab.registry);
assertThatThrownBy(() -> new StudyReplies(lab.registry).run(StudyReplies.Language.EN,
() -> model.answer(ModelStub.PRIVATE))).isInstanceOf(StudyObservations.StudyFailure.class);
assertThat(events.list).isEmpty();
var spans = lab.exporter.getFinishedSpanItems();
assertThat(spans).hasSize(2);
assertThat(spans.stream().filter(s -> s.getName().equals("study.reply")).findFirst()
.orElseThrow().getStatus().getStatusCode()).isEqualTo(StatusCode.ERROR);
assertThat(spans.toString()).doesNotContain(ModelStub.PRIVATE);
} finally {
logger.detachAppender(events);
events.stop();
}
}
@Test void samplingDoesNotRemoveMetricMeasurements() {
try (var lab = new TraceLab(false)) {
new StudyReplies(lab.registry).run(StudyReplies.Language.AZ, () -> "A local study tip");
assertThat(lab.exporter.getFinishedSpanItems()).isEmpty();
assertThat(lab.meters.get("study.reply").timer().count()).isEqualTo(1);
}
}
@Test void closingScopePreventsAnUnrelatedOperationBecomingAChild() {
try (var lab = new TraceLab(true)) {
var replies = new StudyReplies(lab.registry);
replies.run(StudyReplies.Language.EN, () -> "First");
replies.run(StudyReplies.Language.EN, () -> "Second");
var spans = lab.exporter.getFinishedSpanItems();
assertThat(spans).hasSize(2);
assertThat(spans.get(0).getTraceId()).isNotEqualTo(spans.get(1).getTraceId());
assertThat(spans).allSatisfy(s -> assertThat(s.getParentSpanContext().isValid()).isFalse());
}
}
}Boş choices halı iki sərhədi göstərir: adapter exception atmadan tamamlana bilər, sonra tətbiq yararsız nəticəni rədd edir. Tətbiq span-ı uğursuz olmalıdır; uğurlu model span-ı faydalı cavaba zəmanət vermir. Açıq HTTP xətası halında hər iki müşahidə olunan əməliyyat uğursuz olur.
Sampling testi heç bir span ixrac edilmədikdə belə metrika qeyd edir. Scope testi sonrakı əlaqəsiz əməliyyatın yeni trace yaratdığını göstərir. Scope-un açıq qalması sorğuları yanlış əlaqələndirə və konteksti lazım olduğundan uzun saxlaya bilər.
5. Tam lokal yoxlamanı işlədin
Əvvəl tracing testlərini, sonra bütün dərslərin testlərini birlikdə işlədin:
mvn -B -Dtest=TracingTest test
mvn -B testGözlənilən saylar belədir: 3 Actuator, 5 metrika davranışı, 1 metrics endpoint və 7 tracing testi, cəmi 16 test. Heç biri həqiqi provayder və ya collector tələb etmir. Boot testləri loopback ünvanında həqiqi HTTP serverləri başladır. Model testləri Spring AI adapterini lokal stub ilə işlədir. Maven asılılıqları keşə yığıldıqdan sonra asılılıqları oflayn həll edərək təkrarlayın:
mvn -o -B testYoxlamalar valideyn-alt span əlaqəsini, ortaq trace kimliyini, baxılan metrika identifikatorlarında və span-larda süni markerin olmamasını, adapter xəbərdarlığının susdurulmasını, təhlükəsiz xəta hadisələrini, provayder xətalarında tək cəhdi və sampling ilə metrikaların ayrılığını təsdiqləyir. Bunlar heç bir həqiqi məzmunun heç vaxt sızmayacağını və ya xarici exporter, panel və collector-un düzgün qurulduğunu sübut etmir.
6. İstehsala yalnız yoxlanmış davranışı daşıyın
Yerləşdirilmiş xidmətdə dəstəklənən tracing körpüsü və exporter seçin, collector bağlantısını qoruyun, sampling qaydasını və saxlanma müddətini müəyyən edin. Boot-un avtomatik konfiqurasiyası ilə bu əllə qurulmuş test SDK-sı ayrı yanaşmalardır. Hər ikisini quraşdırıb təkrar instrumentasiyanın zərərsiz olacağını düşünməyin.
Çıxan HTTP çağırışlarında kontekstin avtomatik ötürülməsi üçün Boot sənədləri onun avtomatik konfiqurasiya etdiyi RestClient.Builder, RestTemplateBuilder və ya WebClient.Builder istifadə etməyi göstərir. Əllə yaratdığımız lokal client bu ötürülməni nümayiş etdirmir. İstehsal dəyişikliyi qəbul olunan trace başlıqlarını, xidmətlərarası valideyn əlaqəsini və həqiqi sərhəddə xəta davranışını yoxlamalıdır.
Trace ID-ni səlahiyyətli diaqnostika zamanı izi tapmaq üçün istifadə edin, metrika etiketi kimi yox. Prompt, giriş məlumatları və tələbə kimliyini baggage ilə daşımayın. Seçilmiş izlər ayrı-ayrı halları araşdırmağa kömək edir; counter və timer-lər daha geniş iş yükünü təsvir edir. Heç biri cavab keyfiyyətinin nəzarətli qiymətləndirilməsini əvəz etmir.
Tapşırıq: iki span-lı uğurlu testi boş choices testi ilə müqayisə edin. Hansı sərhədin cavabı qəbul etdiyini, hansının rədd etdiyini müəyyən edin. Sonra uzaq HTTP xidmətinin eyni trace-ə qoşulduğunu iddia etməzdən əvvəl lazım olan əlavə testi yazın. Bu iddia lokal alt span-dan daha artıq sübut tələb edir.
Mənbələr: