05 - Entender o que até agora foi feito
Continuação do artigo:
04 - Convertendo para arquitetura hexagonal
Vamos por partes e entender tudo que está acontecendo.
1. O que esse código faz, em linguagem “de gente”
1.1. No Python (treino)
O script train_product_category.py faz:
-
Define um modelo simples em PyTorch (
ProductCategoryNet):-
Entrada: vetor com 16 números (
NUM_FEATURES = 16) — imaginando que seja um embedding do produto. -
Camada oculta de 32 neurônios com ReLU.
-
Saída: 3 neurônios (3 categorias).
-
-
Gera dados sintéticos:
-
Cria 3 “nuvens” de pontos no espaço de 16 dimensões:
-
AUTOPECAS → em torno de 0.0
-
INFORMATICA → em torno de 3.0
-
MATERIAL_CONSTRUCAO → em torno de -3.0
-
-
Embaralha tudo e cria X (features) e y (labels 0,1,2).
-
-
Treina o modelo:
-
Usa CrossEntropyLoss + Adam.
-
Roda algumas épocas e imprime loss e accuracy.
-
-
Exporta para ONNX:
-
Cria um input de teste (“dummy_input”) com shape
[1, 16]. -
Exporta o modelo para
model/model.onnxcom:-
input_names=["input"] -
output_names=["output"] -
dynamic_axespara dimensionar por batch.
-
-
Resultado: arquivo
model.onnxpronto para ser usado em qualquer runtime compatível (no nosso caso, ONNX Runtime no Go).
-
1.2. No Go (arquitetura hexagonal)
A API em Go foi pensada em camadas:
Domínio (internal/domain/category)
-
Define conceitos do negócio:
-
ProductCategory– código/nome da categoria (ex: AUTOPECAS). -
PredictionInput– vetor de features numéricas. -
PredictionResult– categoria + probabilidades.
-
-
Define a porta (interface)
CategoryPredictorPort:- O domínio diz: “eu preciso de alguém que, dado um input, devolva uma categoria”.
Ou seja: nada aqui sabe de ONNX, HTTP, JSON, nada. Só conceito de categorização.
Aplicação (internal/app/service.go)
-
CategoryServicerecebe a portaCategoryPredictorPort. -
Tem o método
PredictProductCategoryque:-
Valida se o vetor tem 16 features.
-
Chama o
predictor.PredictCategory.
-
-
Aqui é o “orquestrador” do caso de uso:
-
Recebe os dados já em forma numérica.
-
Repassa para o modelo (via porta).
-
Retorna o resultado.
-
Ainda não sabe de HTTP, ONNX, logs, etc. Mas já conhece “regras de aplicação” (ex: exatamente 16 features).
Infraestrutura – ONNX (internal/infra/onnx/predictor.go)
-
Implementa a porta
CategoryPredictorPortusando o ONNX Runtime. -
Faz:
-
Carrega o
model.onnxabrindo uma sessão ONNX (NewDynamicSession). -
No
PredictCategory:-
Garante que o tamanho do vetor está certo.
-
Cria um tensor de entrada shape
[1, 16]. -
Cria tensor de saída
[1, 3]. -
Chama
session.Run. -
Lê os logits e aplica
softmaxpara virar probabilidades. -
Encontra a categoria com maior probabilidade e mapeia para o nome (AUTOPECAS, etc).
-
-
Essa camada é totalmente plugável: amanhã você pode trocar ONNX por outra coisa mantendo a interface.
Observabilidade (internal/infra/observability)
-
metrics.go:-
Cria métricas Prometheus:
-
http_requests_total{path,method,status} -
http_request_duration_seconds{path,method}(histograma)
-
-
Cria um middleware que:
-
Mede o tempo da request.
-
Registra o status HTTP.
-
Atualiza os contadores/histogramas.
-
-
Expõe um handler
/metrics(padrão Prometheus).
-
-
tracing.go:-
Inicializa um Tracer OpenTelemetry.
-
Lê
OTEL_EXPORTER_OTLP_ENDPOINTpara mandar traços (spans) via OTLP (gRPC) — normalmente para o Tempo. -
Configura um
TracerProvidercom nome de serviçoproduct-category-api.
-
HTTP (internal/http)
-
handlers.go:-
Recebe o
CategoryService. -
Define:
-
Healthz→ GET/healthz. -
PredictCategory→ POST/predictcom JSON{ "features": [ ...16 floats... ] }.
-
-
Converte request JSON →
PredictionInput, chama o service, devolve JSON com:category_code,category_name,probabilities.
-
-
router.go:-
Monta um
chi.Router. -
Aplica middlewares padrão (
RequestID,Logger, etc.). -
Aplica o
MetricsMiddleware(Prometheus). -
Registra rotas:
-
/healthz -
/predict -
/metrics
-
-
Embrulha tudo com
otelhttp.NewHandlerpara gerar spans de tracing em cada request.
-
cmd/api/main.go
-
Inicializa o ambiente ONNX:
-
Lê
ONNXRUNTIME_LIB(caminho dalibonnxruntime.so). -
Chama
ort.InitializeEnvironment()/DestroyEnvironment().
-
-
Inicializa observabilidade:
-
InitMetrics() -
InitTracer(...)com endpoint OTLP (se tiver).
-
-
Cria
OnnxCategoryPredictorcom:-
modelPath(ex:/app/model/model.onnx). -
nomes de classes (
AUTOPECAS, etc.). -
número de features (16).
-
-
Cria
CategoryService,CategoryHandlerse o router. -
Sobe o HTTP na porta
:8000.
2. Adaptar para receber descrição em vez de vetor de features
Agora vamos dar o passo que você pediu:
“Adapte para receber descrição”
Arquiteturalmente, isso significa:
-
Criar uma porta de embeddings no domínio (ou aplicação).
-
Ter um adapter de infraestrutura que implementa essa porta:
-
No exemplo: um “dummy embedding” (matemática boba só pra exemplo).
-
Em produção: você pluga OpenAI, modelo local, serviço próprio etc.
-
-
Expor um endpoint HTTP que recebe:
{ "descricao": "Pastilha de freio dianteira Corolla 2018" }
-
O fluxo vira:
- descrição → embeddings → modelo ONNX → categoria.
Vou te mostrar só as partes novas/alteradas (pra não repetir tudo).
2.1. Nova porta de embeddings
internal/domain/category/ports.go (adicionar outra interface)
package category
import "context"
// Porta para o modelo de categorização
type CategoryPredictorPort interface {
PredictCategory(ctx context.Context, input PredictionInput) (PredictionResult, error)
}
// Porta para geração de embeddings a partir de descrição
type EmbeddingsPort interface {
EmbedProductDescription(ctx context.Context, description string) ([]float32, error)
}
2.2. Service agora usa predictor e embedder
internal/app/service.go (refatorado)
package app
import (
"context"
"errors"
"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
)
var (
ErrInvalidFeaturesLen = errors.New("quantidade de features inválida; esperado 16")
ErrEmptyDescription = errors.New("descrição do produto não pode ser vazia")
)
const ExpectedFeaturesLen = 16
type CategoryService struct {
predictor category.CategoryPredictorPort
embedder category.EmbeddingsPort
}
func NewCategoryService(p category.CategoryPredictorPort, e category.EmbeddingsPort) *CategoryService {
return &CategoryService{
predictor: p,
embedder: e,
}
}
// Caso de uso original: já recebo as features
func (s *CategoryService) PredictProductCategory(ctx context.Context, input category.PredictionInput) (category.PredictionResult, error) {
if len(input.Features) != ExpectedFeaturesLen {
return category.PredictionResult{}, ErrInvalidFeaturesLen
}
return s.predictor.PredictCategory(ctx, input)
}
// Novo caso de uso: recebo uma descrição em texto
func (s *CategoryService) PredictProductCategoryFromDescription(ctx context.Context, description string) (category.PredictionResult, error) {
if description == "" {
return category.PredictionResult{}, ErrEmptyDescription
}
features, err := s.embedder.EmbedProductDescription(ctx, description)
if err != nil {
return category.PredictionResult{}, err
}
if len(features) != ExpectedFeaturesLen {
return category.PredictionResult{}, ErrInvalidFeaturesLen
}
input := category.PredictionInput{Features: features}
return s.predictor.PredictCategory(ctx, input)
}
2.3. Adapter de embeddings (dummy, plugável)
A ideia aqui é só dar estrutura. Em produção, você troca o corpo da função para usar:
-
OpenAI embeddings,
-
um modelo local,
-
ou uma tabela pré-calculada.
internal/infra/embeddings/dummy.go
package embeddings
import (
"context"
"strings"
"unicode/utf8"
"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
)
const EmbeddingSize = 16
// DummyEmbeddings implementa EmbeddingsPort de forma simples e determinística.
// É só para exemplo / POC. Você deve trocar pela sua engine real de embeddings.
type DummyEmbeddings struct{}
func NewDummyEmbeddings() *DummyEmbeddings {
return &DummyEmbeddings{}
}
func (d *DummyEmbeddings) EmbedProductDescription(ctx context.Context, description string) ([]float32, error) {
// Normaliza: tudo minúsculo
desc := strings.ToLower(description)
vec := make([]float32, EmbeddingSize)
// Regras bobas, só para ter algo numérico:
// - soma de códigos das letras
// - quantidade de palavras
// - comprimentos médios etc.
words := strings.Fields(desc)
wordCount := len(words)
totalRunes := utf8.RuneCountInString(desc)
if wordCount > 0 {
vec[0] = float32(wordCount)
}
vec[1] = float32(totalRunes)
// Distribui caracteres nas posições
for i, r := range desc {
idx := i % EmbeddingSize
vec[idx] += float32(r % 64) // joga mod 64 só pra não explodir
}
// Normalização bem tosca (apenas exemplo)
for i := range vec {
vec[i] = vec[i] / 100.0
}
return vec, nil
}
Depois, quando você tiver embeddings reais, é só criar outro adapter, ex.:
internal/infra/embeddings/openai.go
e trocar na composição nomain.
2.4. Handlers HTTP agora aceitam descrição
Vamos manter dois endpoints:
-
/predict→ recebe features diretas (debug, testes). -
/predict-description→ recebe descrição.
internal/http/handlers.go (versão adaptada)
package http
import (
"encoding/json"
"net/http"
"github.com/seuuser/ml-onnx-example/api-go/internal/app"
"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
)
type CategoryHandlers struct {
svc *app.CategoryService
}
func NewCategoryHandlers(svc *app.CategoryService) *CategoryHandlers {
return &CategoryHandlers{svc: svc}
}
type predictRequest struct {
Features []float32 `json:"features"`
}
type predictDescriptionRequest struct {
Descricao string `json:"descricao"`
}
type predictResponse struct {
CategoryCode string `json:"category_code"`
CategoryName string `json:"category_name"`
Probabilities []float32 `json:"probabilities"`
}
func (h *CategoryHandlers) Healthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// Continua existindo: recebe vetor numérico
func (h *CategoryHandlers) PredictCategory(w http.ResponseWriter, r *http.Request) {
var req predictRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "JSON inválido")
return
}
input := category.PredictionInput{
Features: req.Features,
}
result, err := h.svc.PredictProductCategory(r.Context(), input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
resp := predictResponse{
CategoryCode: result.Category.Code,
CategoryName: result.Category.Name,
Probabilities: result.Probabilities,
}
writeJSON(w, http.StatusOK, resp)
}
// Novo endpoint: recebe descrição em texto
func (h *CategoryHandlers) PredictCategoryFromDescription(w http.ResponseWriter, r *http.Request) {
var req predictDescriptionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "JSON inválido")
return
}
result, err := h.svc.PredictProductCategoryFromDescription(r.Context(), req.Descricao)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
resp := predictResponse{
CategoryCode: result.Category.Code,
CategoryName: result.Category.Name,
Probabilities: result.Probabilities,
}
writeJSON(w, http.StatusOK, resp)
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
internal/http/router.go (registrando nova rota)
package http
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/seuuser/ml-onnx-example/api-go/internal/infra/observability"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func NewRouter(h *CategoryHandlers) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(observability.MetricsMiddleware)
r.Get("/healthz", h.Healthz)
r.Post("/predict", h.PredictCategory)
r.Post("/predict-description", h.PredictCategoryFromDescription)
r.Handle("/metrics", observability.MetricsHandler())
return otelhttp.NewHandler(r, "product-category-api")
}
2.5. main.go agora instancia o embedder também
cmd/api/main.go (trecho de montagem)
package main
import (
"context"
"log"
"net/http"
"os"
"time"
ort "github.com/yalue/onnxruntime_go"
"github.com/seuuser/ml-onnx-example/api-go/internal/app"
apihttp "github.com/seuuser/ml-onnx-example/api-go/internal/http"
"github.com/seuuser/ml-onnx-example/api-go/internal/infra/embeddings"
"github.com/seuuser/ml-onnx-example/api-go/internal/infra/observability"
"github.com/seuuser/ml-onnx-example/api-go/internal/infra/onnx"
)
func main() {
ctx := context.Background()
if libPath := os.Getenv("ONNXRUNTIME_LIB"); libPath != "" {
ort.SetSharedLibraryPath(libPath)
}
if err := ort.InitializeEnvironment(); err != nil {
log.Fatalf("erro inicializando onnxruntime: %v", err)
}
defer func() {
if err := ort.DestroyEnvironment(); err != nil {
log.Printf("erro destruindo ambiente onnxruntime: %v", err)
}
}()
observability.InitMetrics()
shutdownTracer, err := observability.InitTracer(ctx, "product-category-api")
if err != nil {
log.Printf("erro inicializando tracer: %v", err)
} else {
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := shutdownTracer(ctx); err != nil {
log.Printf("erro encerrando tracer: %v", err)
}
}()
}
modelPath := os.Getenv("MODEL_PATH")
if modelPath == "" {
modelPath = "model/model.onnx"
}
classNames := []string{"AUTOPECAS", "INFORMATICA", "MATERIAL_CONSTRUCAO"}
expectedFeatures := 16
predictor, err := onnx.NewOnnxCategoryPredictor(modelPath, classNames, expectedFeatures)
if err != nil {
log.Fatalf("erro criando predictor ONNX: %v", err)
}
embedder := embeddings.NewDummyEmbeddings()
svc := app.NewCategoryService(predictor, embedder)
handlers := apihttp.NewCategoryHandlers(svc)
router := apihttp.NewRouter(handlers)
addr := ":8000"
log.Printf("API de categorização de produtos ouvindo em %s", addr)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("erro servidor HTTP: %v", err)
}
}
Testando o endpoint com descrição
curl -X POST http://localhost:8000/predict-description \
-H "Content-Type: application/json" \
-d '{"descricao": "Pastilha de freio dianteira Corolla 2018"}'
3. docker-compose com Prometheus + Grafana + Loki + Tempo
Agora o cenário completo:
-
api-go– nossa API (com/metricse OTEL OTLP). -
prometheus– coleta métricas em/metrics. -
loki– armazena logs. -
promtail– coleta logs dos containers e envia pra Loki. -
tempo– guarda traces OTEL. -
grafana– dashboard unificado.
3.1. Estrutura de arquivos
docker-compose.yml
prometheus/
prometheus.yml
loki/
config.yml
tempo/
tempo.yml
promtail/
config.yml
grafana/
provisioning/
datasources/
datasources.yml
dashboards/
dashboards.yml
dashboards/
product-api.json
3.2. docker-compose.yml
version: "3.9"
services:
trainer:
build:
context: .
dockerfile: Dockerfile.ml
volumes:
- ./model:/app/model
command: ["python", "ml/train_product_category.py"]
api-go:
build:
context: .
dockerfile: Dockerfile.api-go
volumes:
- ./model:/app/model:ro
environment:
- ONNXRUNTIME_LIB=/usr/local/lib/libonnxruntime.so
- MODEL_PATH=/app/model/model.onnx
- OTEL_EXPORTER_OTLP_ENDPOINT=tempo:4317
ports:
- "8000:8000"
depends_on:
- tempo
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
command:
- "--config.file=/etc/prometheus/prometheus.yml"
ports:
- "9090:9090"
depends_on:
- api-go
loki:
image: grafana/loki:3.0.0
command: -config.file=/etc/loki/config.yml
volumes:
- ./loki/config.yml:/etc/loki/config.yml
ports:
- "3100:3100"
promtail:
image: grafana/promtail:3.0.0
command: -config.file=/etc/promtail/config.yml
volumes:
- ./promtail/config.yml:/etc/promtail/config.yml
- /var/log:/var/log
- /var/lib/docker/containers:/var/lib/docker/containers
depends_on:
- loki
tempo:
image: grafana/tempo:2.6.0
command: [ "-config.file=/etc/tempo/tempo.yml" ]
volumes:
- ./tempo/tempo.yml:/etc/tempo/tempo.yml
ports:
- "3200:3200" # http
- "4317:4317" # otlp-grpc
grafana:
image: grafana/grafana:11.0.0
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
ports:
- "3000:3000"
depends_on:
- prometheus
- loki
- tempo
volumes:
- ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources
- ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards
- ./grafana/dashboards:/var/lib/grafana/dashboards
Obs.: o
promtailprecisa ter acesso aos logs do Docker do host; se estiver usando Docker Desktop/WSL, pode precisar ajustar os paths.
3.3. Prometheus – prometheus/prometheus.yml
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'api-go'
static_configs:
- targets: ['api-go:8000']
3.4. Loki – loki/config.yml (bem simples)
server:
http_listen_port: 3100
distributor:
ring:
kvstore:
store: inmemory
ingester:
lifecycler:
ring:
kvstore:
store: inmemory
replication_factor: 1
chunk_idle_period: 1h
max_chunk_age: 1h
chunk_target_size: 1048576
querier:
engine:
timeout: 60s
query_range:
parallelise_shardable_queries: true
split_queries_by_interval: 15m
schema_config:
configs:
- from: 2024-01-01
store: boltdb-shipper
object_store: filesystem
schema: v12
index:
prefix: index_
period: 24h
storage_config:
boltdb_shipper:
active_index_directory: /loki/index
cache_location: /loki/index_cache
shared_store: filesystem
filesystem:
directory: /loki/chunks
ruler:
storage:
type: local
local:
directory: /loki/rules
rule_path: /loki/rules-temp
ring:
kvstore:
store: inmemory
compactor:
working_directory: /loki/compactor
shared_store: filesystem
3.5. Promtail – promtail/config.yml (coletando logs de containers)
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker-logs
static_configs:
- targets:
- localhost
labels:
job: docker
__path__: /var/lib/docker/containers/*/*.log
3.6. Tempo – tempo/tempo.yml
server:
http_listen_port: 3200
grpc_listen_port: 4317
distributor:
receivers:
otlp:
protocols:
grpc:
ingester:
traces:
wal:
enabled: false
compactor:
compaction:
block_retention: 24h
storage:
trace:
backend: local
local:
path: /tmp/tempo/traces
3.7. Grafana – datasources
grafana/provisioning/datasources/datasources.yml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
- name: Loki
type: loki
access: proxy
url: http://loki:3100
- name: Tempo
type: tempo
access: proxy
url: http://tempo:3200
3.8. Grafana – provisionamento de dashboards
grafana/provisioning/dashboards/dashboards.yml
apiVersion: 1
providers:
- name: 'product-api'
orgId: 1
folder: 'APIs'
type: file
disableDeletion: false
updateIntervalSeconds: 10
options:
path: /var/lib/grafana/dashboards
3.9. Dashboard simples – grafana/dashboards/product-api.json
Um dashboard bem resumido, com:
-
painel de requests por segundo,
-
painel de latência,
-
painel de erros,
-
Exploração de logs (Loki) e traces (Tempo) você faz pela UI.
{
"id": null,
"uid": "product-api",
"title": "Product Category API",
"schemaVersion": 38,
"version": 1,
"panels": [
{
"type": "stat",
"title": "Requests por segundo",
"targets": [
{
"expr": "sum(rate(http_requests_total[1m]))",
"refId": "A"
}
],
"gridPos": { "x": 0, "y": 0, "w": 8, "h": 6 }
},
{
"type": "graph",
"title": "Latência (p95)",
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
"refId": "A"
}
],
"gridPos": { "x": 8, "y": 0, "w": 16, "h": 6 }
},
{
"type": "stat",
"title": "Taxa de erros (5xx)",
"targets": [
{
"expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m]))",
"refId": "A"
}
],
"gridPos": { "x": 0, "y": 6, "w": 8, "h": 6 }
}
],
"time": {
"from": "now-15m",
"to": "now"
}
}
4. Como rodar o conjunto todo
- Treinar o modelo (gera
model/model.onnx):
docker compose run --rm trainer
- Subir o stack inteiro (API + observabilidade):
docker compose up -d api-go prometheus loki promtail tempo grafana
- Testar a API:
curl -X POST http://localhost:8000/predict-description \
-H "Content-Type: application/json" \
-d '{"descricao": "SSD 1TB NVMe PCIe 4.0 para notebook gamer"}'
- Abrir o Grafana:
-
Acesse
http://localhost:3000 -
login:
admin / admin -
Dashboard: “Product Category API”
-
DataSources: Prometheus, Loki, Tempo já configurados.
Ver depois:
06 - Trocando o dummy embeddings por algo que consigamos plugar facilmente