Spring AI · Metrikalar və sorğuların izlənməsi · Dərs 2

Micrometer ilə metrikaların toplanması

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ə.

Micrometer ilə metrikaların toplanması

1. İnsanları etiketləmədən əməliyyatları ölçün

Tələbə Study Coach-un nahardan sonra yavaşladığını deyir. Health hələ də UP göstərir. Araşdırmaq üçün sorğular üzrə ölçmələr lazımdır: əməliyyat sayı, keçən vaxt, uğursuzluqlar və modelin bildirdiyi token istifadəsi.

Observation əməliyyatın həyat dövrünü təsvir edir. Qeydiyyatdan keçmiş handler-lər bu hadisələri metrikalara və ya span-lara çevirir. Uyğun handler olmadan observation yaratmaq bütün siqnalları avtomatik yaratmır. Timer artıq say və ümumi müddət saxlayır. Eyni tamamlanmış əməliyyatları ayrıca counter ilə saymağa adətən ehtiyac yoxdur.

Actuator layihəsini davam etdirin. Bütöv Study Coach əməliyyatı üçün study.reply, model adapterinin çağırışı üçün isə Spring AI-nin gen_ai.client.operation adından istifadə edəcəyik. Bu timer-lər fərqli sərhədləri ölçür. Onların müddətlərini bir-birindən müstəqil işlər kimi toplamayın.

Metrika etiketlərinin mümkün qiymətləri məhdud olmalıdır. language=EN|AZ və outcome=success|failure faydalıdır. Tələbə ID-si, sual, cavab mətni, sorğu ID-si və trace ID metrika etiketi üçün uyğun deyil. Oxuna bilən şəxsi məlumat daşımayan identifikator belə hər sorğu üçün yeni zaman sırası yarada bilər.

2. Metrika handler-lərini və təhlükəsiz xəta siyasətini qoşun

Actuator olduqda Boot ümumi metrika observation handler-ini artıq təmin edir. Açıq şəkildə yaratdığımız model üçün token handler-ini əlavə edin. local fabriki adi Java testlərinə uyğun metrika bağlantısını qurur; onun ümumi handler-ini Boot registry-sində təkrar qeydiyyatdan keçirməyin.

İlk handler digər handler-lər orijinal mesajı və ya səbəbi ixrac etməzdən əvvəl observation xətasını sabit exception ilə əvəz edir. Bu, bilərəkdən sərt seçimdir: laboratoriya exception növlərinin təfərrüatını itirir. İstehsal siyasəti məlum xətaları kiçik, təsdiqlənmiş qrupa çevirə bilər, amma istənilən exception mesajını etiketə və ya hadisəyə köçürməməlidir. Boot bean-i ən yüksək prioritetə malikdir; ayrıca registry-də isə ilk qeydiyyatdan keçir.

src/main/java/academy/study/observability/StudyObservations.java

Java
package academy.study.observability;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.observation.DefaultMeterObservationHandler;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationHandler;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.chat.observation.ChatModelMeterObservationHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;

@Configuration(proxyBeanMethods = false)
public class StudyObservations {
    @Bean SafeErrors safeErrors() { return new SafeErrors(); }

    @Bean ChatModelMeterObservationHandler tokenMetrics(MeterRegistry meters) {
        return new ChatModelMeterObservationHandler(meters);
    }

    public static ObservationRegistry local(MeterRegistry meters) {
        var registry = ObservationRegistry.create();
        registry.observationConfig()
                .observationHandler(new SafeErrors())
                .observationHandler(new DefaultMeterObservationHandler(meters))
                .observationHandler(new ChatModelMeterObservationHandler(meters));
        return registry;
    }

    public static final class StudyFailure extends RuntimeException {
        public StudyFailure() { super("Study operation failed"); }
    }

    public static final class SafeErrors
            implements ObservationHandler<Observation.Context>, Ordered {
        @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; }
        @Override public boolean supportsContext(Observation.Context context) { return true; }
        @Override public void onError(Observation.Context context) {
            context.setError(new StudyFailure());
        }
    }
}

3. Tətbiq əməliyyatının sərhədini müəyyən edin

Dil enum ilə verilir, nəticənin isə iki mümkün qiyməti var. Observation scope daxildəki işi bu əməliyyatla əlaqələndirir. Uğur və xəta zamanı həm scope, həm də observation bağlanır. Supplier-in orijinal mesajı əvəzinə təhlükəsiz exception ötürürük.

src/main/java/academy/study/observability/StudyReplies.java

Java
package academy.study.observability;

import java.util.Objects;
import java.util.function.Supplier;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.stereotype.Component;

@Component
public class StudyReplies {
    public enum Language { EN, AZ }
    private final ObservationRegistry registry;

    public StudyReplies(ObservationRegistry registry) { this.registry = registry; }

    public String run(Language language, Supplier<String> work) {
        Objects.requireNonNull(language);
        Objects.requireNonNull(work);
        var observation = Observation.createNotStarted("study.reply", registry)
                .lowCardinalityKeyValue("language", language.name())
                .lowCardinalityKeyValue("outcome", "success").start();
        try (var scope = observation.openScope()) {
            return work.get();
        } catch (RuntimeException failure) {
            observation.lowCardinalityKeyValue("outcome", "failure");
            observation.error(new StudyObservations.StudyFailure());
            throw new StudyObservations.StudyFailure();
        } finally {
            observation.stop();
        }
    }
}

Bu controller sabit, açıq tədris məsləhəti qaytarır. Model çağırışı və token xərci olmadan tətbiqin observation-ını HTTP vasitəsilə işlədir.

src/main/java/academy/study/observability/TipController.java

Java
package academy.study.observability;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TipController {
    private final StudyReplies replies;
    public TipController(StudyReplies replies) { this.replies = replies; }

    @GetMapping("/study/tip")
    public String tip() {
        return replies.run(StudyReplies.Language.EN,
                () -> "Use a bounded queue for study jobs.");
    }
}

Bu əlavə profili saxlayın. Yalnız seçildikdə diaqnostik metrics endpoint-ini açır və birinci dərsin loopback ünvanını saxlayır. Bu endpoint Prometheus scrape endpoint-i və ya zaman sıraları bazası deyil.

src/main/resources/application-metrics-lab.yml

yaml
management:
  endpoints:
    web:
      exposure:
        include: health,metrics

4. Açıq şəkildə yaradılan Spring AI modelini müşahidə edin

Spring AI-nin standart chat convention-u sorğu və cavab modelinin adlarını aşağı kardinalıqlı qiymətlər kimi, bir neçə başqa qiyməti isə yüksək kardinalıqlı qiymətlər kimi əlavə edir. Yüksək kardinalıq məxfilik kateqoriyası deyil. Cavab ID-si və ya dayandırma ardıcıllığı da həssas məlumat daşıya bilər.

Bizim convention standart əməliyyat adını saxlayır, etiketləri isə icazə verilən siyahı ilə əvəz edir. study.model.profile=local-stub həqiqi provayder modelini deyil, sabit konfiqurasiya profilini bildirir. Provayderin qaytardığı model adı və bütün yüksək kardinalıqlı qiymətlər kənarda qalır. gen_ai.system=openai laboratoriyada istifadə olunan adapter formatını göstərir; HTTP xidməti özü lokal stub-dur.

src/main/java/academy/study/observability/SafeChatConvention.java

Java
package academy.study.observability;

import io.micrometer.common.KeyValues;
import org.springframework.ai.chat.observation.ChatModelObservationContext;
import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention;

public final class SafeChatConvention extends DefaultChatModelObservationConvention {
    @Override public String getContextualName(ChatModelObservationContext context) {
        return "study.model";
    }
    @Override public KeyValues getLowCardinalityKeyValues(ChatModelObservationContext context) {
        return KeyValues.of("gen_ai.operation.name", "chat", "gen_ai.system", "openai",
                "study.model.profile", "local-stub");
    }
    @Override public KeyValues getHighCardinalityKeyValues(ChatModelObservationContext context) {
        return KeyValues.empty();
    }
}

Aşağıdakı adapter yalnız rəqəmlə yazılmış loopback URL-ni qəbul edir, süni giriş məlumatından istifadə edir, yönləndirmələri söndürür, qoşulma və oxuma müddətini məhdudlaşdırır və bir cəhd edir. lab-observed stub-un qəbul etdiyi uydurma model adıdır. Provayder modeli üçün tövsiyə deyil. Eyni registry həqiqi Spring AI modelinə verilir.

src/main/java/academy/study/observability/ObservedStudyModel.java

Java
package academy.study.observability;

import java.net.URI;
import java.net.http.HttpClient;
import java.time.Duration;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.RestClient;

public final class ObservedStudyModel {
    private final OpenAiChatModel model;

    public ObservedStudyModel(URI base, ObservationRegistry registry) {
        if (!"http".equals(base.getScheme()) || !"127.0.0.1".equals(base.getHost())
                || base.getPort() < 1 || !base.getPath().isEmpty()
                || base.getQuery() != null || base.getFragment() != null
                || base.getUserInfo() != null) {
            throw new IllegalArgumentException("A local stub address is required");
        }
        var http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2))
                .followRedirects(HttpClient.Redirect.NEVER).build();
        var factory = new JdkClientHttpRequestFactory(http);
        factory.setReadTimeout(Duration.ofSeconds(3));
        var api = OpenAiApi.builder().baseUrl(base.toString()).apiKey("local-stub-key")
                .restClientBuilder(RestClient.builder().requestFactory(factory))
                .responseErrorHandler(new org.springframework.web.client.ResponseErrorHandler() {
                    @Override public boolean hasError(
                            org.springframework.http.client.ClientHttpResponse response)
                            throws java.io.IOException {
                        return !response.getStatusCode().is2xxSuccessful();
                    }
                    @Override public void handleError(
                            org.springframework.http.client.ClientHttpResponse response) {
                        throw new StudyObservations.StudyFailure();
                    }
                }).build();
        model = OpenAiChatModel.builder().openAiApi(api)
                .defaultOptions(OpenAiChatOptions.builder().model("lab-observed")
                        .maxTokens(100).internalToolExecutionEnabled(false).build())
                .retryTemplate(RetryTemplate.builder().maxAttempts(1).build())
                .observationRegistry(registry).build();
        model.setObservationConvention(new SafeChatConvention());
    }

    public String answer(String question) {
        if (question == null || question.isBlank() || question.length() > 1000) {
            throw new IllegalArgumentException("Question must contain 1 to 1000 characters");
        }
        try {
            var response = model.call(new Prompt(question));
            if (response == null || response.getResults().size() != 1
                    || response.getResult().getOutput().hasToolCalls()) {
                throw new StudyObservations.StudyFailure();
            }
            String text = response.getResult().getOutput().getText();
            if (text == null || text.isBlank() || text.length() > 1000) {
                throw new StudyObservations.StudyFailure();
            }
            return text;
        } catch (RuntimeException failure) {
            throw new StudyObservations.StudyFailure();
        }
    }
}

Sual və qaytarılan mətn üçün simvol həddi var. Bu, etibar edilməyən HTTP cavabının ümumi ölçü məhdudlaşdırıcısı deyil: adapter bizim mətn yoxlamamızdan əvvəl cavabı dekodlaşdırır. Stub etibarlı sayılır və kiçik, sabit cavablar verir. İstehsal mühitində HTTP sərhədi üçün ayrıca gövdə hədləri və provayderə uyğun yoxlama lazımdır.

Birinci dərsdəki adapter logger parametrini saxlayın. Adi Java testlərinin Boot başlamadan eyni log məhdudiyyətindən istifadə etməsi üçün bu test konfiqurasiyasını da əlavə edin. Burada bilərəkdən log appender yoxdur. Sonrakı tracing testləri boş choices xəbərdarlığının yolunu yoxlamaq üçün müvəqqəti appender qoşur.

src/test/resources/logback-test.xml

xml
<configuration>
  <logger name="org.springframework.ai.openai.OpenAiChatModel" level="OFF"/>
  <root level="WARN"/>
</configuration>

5. Sayğacları, xətaları və endpoint girişini yoxlayın

Stub süni məzmun və sabit istifadə qaytarır: hər uğurlu çağırış üçün 12 giriş tokeni, 7 çıxış tokeni və cəmi 19 token. Spring AI 1.1.8 bunları gen_ai.client.token.usage altında counter kimi qeyd edir və gen_ai.token.type ilə fərqləndirir. Cəm ayrıca sıradır. Giriş, çıxış və cəmi birlikdə toplamayın; bu nümunədə istifadəni iki dəfə saymış olarsınız.

Aşağıdakı bütün Java faylları test mənbə qovluğunda saxlanmalıdır. Şəxsi məlumatı təmsil edən marker saxta test məlumatıdır və bilərəkdən prompt, cavab, cavab ID-si və model adına yerləşdirilib. Onun metrika identifikatorlarında olmaması faydalı reqressiya yoxlamasıdır, bütün mümkün loglama və ixrac yolları haqqında sübut deyil.

src/test/java/academy/study/observability/ModelStub.java

Java
package academy.study.observability;

import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;

final class ModelStub implements AutoCloseable {
    static final String PRIVATE = "synthetic-private-marker";
    final AtomicInteger calls = new AtomicInteger();
    final HttpServer server;
    volatile int status = 200;
    volatile String body = """
            {"id":"synthetic-private-marker","object":"chat.completion","created":1,
             "model":"synthetic-private-marker",
             "choices":[{"index":0,"message":{"role":"assistant",
             "content":"synthetic-private-marker"},"finish_reason":"stop"}],
             "usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19}}
            """;

    ModelStub() throws IOException {
        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/v1/chat/completions", exchange -> {
            calls.incrementAndGet();
            try (exchange) {
                exchange.getRequestBody().readNBytes(4096);
                byte[] data = body.getBytes(StandardCharsets.UTF_8);
                exchange.getResponseHeaders().set("Content-Type", "application/json");
                exchange.sendResponseHeaders(status, data.length);
                exchange.getResponseBody().write(data);
            }
        });
        server.start();
    }
    URI uri() { return URI.create("http://127.0.0.1:" + server.getAddress().getPort()); }
    @Override public void close() { server.stop(0); }
}

src/test/java/academy/study/observability/MetricsTest.java

Java
package academy.study.observability;

import java.net.URI;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationHandler;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class MetricsTest {
    final SimpleMeterRegistry meters = new SimpleMeterRegistry();
    @org.junit.jupiter.api.AfterEach void closeMeters() { meters.close(); }
    @Test void modelTokensAndOperationTimersComeFromRealAdapter() throws Exception {
        try (var stub = new ModelStub()) {
            var registry = StudyObservations.local(meters);
            var replies = new StudyReplies(registry);
            var model = new ObservedStudyModel(stub.uri(), registry);
            for (int i = 0; i < 3; i++) {
                String question = ModelStub.PRIVATE + i;
                assertThat(replies.run(StudyReplies.Language.EN, () -> model.answer(question)))
                        .isEqualTo(ModelStub.PRIVATE);
            }
            assertThat(meters.get("study.reply").tag("outcome", "success").timer().count()).isEqualTo(3);
            assertThat(meters.get("gen_ai.client.operation").timer().count()).isEqualTo(3);
            assertThat(meters.get("gen_ai.client.token.usage").tag("gen_ai.token.type", "input")
                    .counter().count()).isEqualTo(36);
            assertThat(meters.get("gen_ai.client.token.usage").tag("gen_ai.token.type", "output")
                    .counter().count()).isEqualTo(21);
            assertThat(meters.get("gen_ai.client.token.usage").tag("gen_ai.token.type", "total")
                    .counter().count()).isEqualTo(57);
            assertThat(meters.get("study.reply").timers()).hasSize(1);
            assertThat(meters.get("gen_ai.client.operation").timers()).hasSize(1);
            assertThat(meters.getMeters().stream().map(m -> m.getId().toString()).toList().toString())
                    .doesNotContain(ModelStub.PRIVATE);
            assertThat(registry.getCurrentObservation()).isNull();
        }
    }

    @Test void failureHasBoundedErrorAndNoInventedTokenUsage() throws Exception {
        try (var stub = new ModelStub()) {
            stub.status = 429;
            stub.body = ModelStub.PRIVATE;
            var registry = StudyObservations.local(meters);
            var replies = new StudyReplies(registry);
            var model = new ObservedStudyModel(stub.uri(), registry);
            assertThatThrownBy(() -> replies.run(StudyReplies.Language.AZ,
                    () -> model.answer(ModelStub.PRIVATE)))
                    .isInstanceOf(StudyObservations.StudyFailure.class)
                    .hasMessage("Study operation failed").hasNoCause();
            assertThat(stub.calls).hasValue(1);
            assertThat(meters.get("study.reply").tag("outcome", "failure").timer().count()).isEqualTo(1);
            assertThat(meters.find("gen_ai.client.token.usage").counters()).isEmpty();
            assertThat(registry.getCurrentObservation()).isNull();
        }
    }

    @Test void errorsAreSanitizedBeforeLaterHandlers() {
        var registry = StudyObservations.local(meters);
        registry.observationConfig().observationHandler(new ObservationHandler<Observation.Context>() {
            @Override public boolean supportsContext(Observation.Context context) { return true; }
            @Override public void onError(Observation.Context context) {
                assertThat(context.getError()).isInstanceOf(StudyObservations.StudyFailure.class)
                        .hasMessage("Study operation failed").hasNoCause();
            }
        });
        var observation = Observation.start("study.test", registry);
        observation.error(new IllegalArgumentException(ModelStub.PRIVATE));
        observation.stop();
    }

    @Test void invalidQuestionNeverCallsProvider() throws Exception {
        try (var stub = new ModelStub()) {
            var model = new ObservedStudyModel(stub.uri(), StudyObservations.local(meters));
            for (String question : new String[]{"", " ", "x".repeat(1001)}) {
                assertThatThrownBy(() -> model.answer(question)).isInstanceOf(IllegalArgumentException.class);
            }
            assertThat(stub.calls).hasValue(0);
        }
    }

    @Test void externalBaseUrlIsRejected() {
        assertThatThrownBy(() -> new ObservedStudyModel(URI.create("https://example.com"),
                StudyObservations.local(meters))).isInstanceOf(IllegalArgumentException.class);
    }
}

Altı metrika testini işlədin. Əvvəlki dərsin ActuatorTest.java faylını saxlayın, çünki onun URL köməkçisi təkrar istifadə olunur:

src/test/java/academy/study/observability/MetricsEndpointTest.java

Java
package academy.study.observability;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.server.LocalManagementPort;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;

@ActiveProfiles("metrics-lab")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MetricsEndpointTest {
    @LocalServerPort int appPort;
    @LocalManagementPort int managementPort;
    @Autowired TestRestTemplate http;

    @Test void metricsEndpointShowsTheCustomOperation() {
        var reply = http.getForEntity(ActuatorTest.url(appPort, "/study/tip"), String.class);
        assertThat(reply.getBody()).isEqualTo("Use a bounded queue for study jobs.");
        var metric = http.getForEntity(ActuatorTest.url(managementPort,
                "/actuator/metrics/study.reply"), String.class);
        assertThat(metric.getStatusCode().value()).isEqualTo(200);
        assertThat(metric.getBody()).contains("study.reply", "COUNT", "language", "outcome")
                .doesNotContain(reply.getBody());
        assertThat(http.getForEntity(ActuatorTest.url(managementPort, "/actuator/env"), String.class)
                .getStatusCode().value()).isEqualTo(404);
    }
}
Terminal
mvn -B -Dtest=MetricsTest,MetricsEndpointTest test

Testlər üç uğurlu çağırışdan sonra giriş 36, çıxış 21 və cəm 57 gözləyir. Ölçülən hər əməliyyat üçün bir timer sırası yaranır. Rədd edilmiş çağırış bir HTTP cəhdi edir və uydurma istifadə göstəricisi olmadan uğursuzluq ölçməsi yaradır. HTTP testi metrics-lab profilini aktivləşdirir, study.reply göstəricisini oxuyur və /actuator/env ünvanının bağlı qaldığını təsdiqləyir.

6. Ölçmələri imkanları daxilində şərh edin

/actuator/metrics/study.reply bu prosesdəki metrikaların ölçmələrini və mövcud etiketlərini göstərir. Timer sayı həmin registry üçün yığılır; prosesin yenidən başlaması yaddaşdakı tarixçəni sıfırlayır. Sürəti hesablamaq üçün müəyyən intervaldakı ölçmələr lazımdır. Persentillər və histoqramlar ayrıca konfiqurasiya və uyğun monitorinq sistemi tələb edir. Bu dərs istehsal üçün p95 paneli yaratmır.

Token sayğacları provayder adapterinin qaytardığı istifadəni əks etdirir. İstifadə məlumatının olmaması xərcin sıfır olması demək deyil. Uğursuz və ya təkrar cəhd edilən əməliyyatlar yararlı cavab vermədən hesablaşmaya düşə bilər. Keş, məlumat növləri və provayderin qiymət qaydaları xərcə təsir edir. Bu laboratoriyada təkrar cəhd və həqiqi ödəniş yoxdur. Bütün token cəmini bir universal qiymətə vurub nəticəni hesab-faktura saymayın.

Tətbiq timer-i model çağırışı ətrafındakı yoxlamanı və cavab emalını da əhatə edir. Model timer-i uğurlu olduğu halda, sonrakı tətbiq yoxlaması uğursuz ola bilər. Heç biri cavabın düzgünlüyünü sübut etmir. Əməliyyat ölçmələrini əvvəlki dərslərin ayrıca qiymətləndirmə üsulları ilə birlikdə istifadə edin.

Tapşırıq: ikinci icazəli dildə çağırış əlavə edin və yalnız gözlənilən əlavə language sırasının yarandığını yoxlayın. Sonra min fərqli tələbə sualını düşünün. Fərqli sualların sayı etiket dəstinə təsir etməməlidir. Növbəti dərs bir sorğunun icrasını izlər vasitəsilə araşdırır.

Mənbələr: