diff --git a/.gitignore b/.gitignore index e43b0f9..bde4e77 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +**/.env \ No newline at end of file diff --git a/learning-otel-go/.env.example b/learning-otel-go/.env.example new file mode 100644 index 0000000..4d19454 --- /dev/null +++ b/learning-otel-go/.env.example @@ -0,0 +1,21 @@ +# Configurações do servidor +PORT=8080 +ENV=development + +# Configurações do banco de dados +DB_HOST=localhost +DB_PORT=5432 +DB_USER=admin +DB_PASS=admin123 +DB_NAME=golearn_db + +# Configurações de logs +LOG_LEVEL=info + +# OpenTelemetry +# Para usar o OpenTelemetry Collector (recomendado): +OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 +# O OpenTelemetry Collector encaminhará traces para Jaeger e métricas para seus destinos configurados + +# Segurança +API_KEY=sua_chave_secreta_aqui \ No newline at end of file diff --git a/learning-otel-go/LICENSE b/learning-otel-go/LICENSE new file mode 100644 index 0000000..d03742c --- /dev/null +++ b/learning-otel-go/LICENSE @@ -0,0 +1,28 @@ +The MIT License (MIT) + +Original Work +Copyright (c) 2016 Matthias Kadenbach +https://github.com/mattes/migrate + +Modified Work +Copyright (c) 2018 Dale Hui +https://github.com/golang-migrate/migrate + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/learning-otel-go/README.md b/learning-otel-go/README.md new file mode 100644 index 0000000..e9fd233 --- /dev/null +++ b/learning-otel-go/README.md @@ -0,0 +1,165 @@ +# API de Tarefas com OpenTelemetry em Go + +Este projeto é um exemplo prático de como implementar telemetria usando OpenTelemetry em uma aplicação Go. A aplicação consiste em uma API de gerenciamento de tarefas (todo list) com uma arquitetura em camadas e persistência em PostgreSQL. + +## Estrutura do Projeto + +O projeto segue uma arquitetura em camadas: + +``` +todo-api/ +├── cmd/ +│ └── migrate/ # Ferramenta para migrações do banco de dados +├── db/ +│ └── migrations/ # Migrações SQL +├── internal/ +│ ├── config/ # Configurações da aplicação +│ ├── core/ +│ │ └── task/ # Domínio e regras de negócio +│ ├── handler/ # Handlers HTTP +│ ├── router/ # Configuração de rotas +│ └── telemetry/ # Configuração OpenTelemetry +└── main.go # Ponto de entrada da aplicação +``` + +## Tecnologias Utilizadas + +### Bibliotecas Principais +- **gorilla/mux**: Router HTTP +- **lib/pq**: Driver PostgreSQL para Go +- **golang-migrate/migrate**: Migrações de banco de dados +- **google/uuid**: Geração de identificadores únicos +- **joho/godotenv**: Carregamento de variáveis de ambiente + +### OpenTelemetry +- **go.opentelemetry.io/otel**: API principal do OpenTelemetry +- **go.opentelemetry.io/otel/trace**: API de traces +- **go.opentelemetry.io/otel/sdk**: Implementação do SDK +- **go.opentelemetry.io/otel/exporters/otlp**: Exportadores para OTLP +- **go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc**: Exportador gRPC + +## Funcionalidades + +A API implementa operações CRUD para tarefas: + +- **GET /tasks**: Lista todas as tarefas +- **POST /task**: Cria uma nova tarefa +- **PUT /task/{id}**: Atualiza uma tarefa existente +- **DELETE /task/{id}**: Remove uma tarefa +- **GET /health**: Endpoint de verificação de saúde + +## Implementação do OpenTelemetry + +A telemetria foi implementada em várias camadas da aplicação: + +### 1. Inicialização (internal/telemetry/tracing.go) + +Configuração do provedor de traces, exportador OTLP e propagadores: + +```go +func InitTracer(serviceName string, collectorURL string) (func(context.Context) error, error) { + // Configuração do tracer, exportador e propagadores + // ... +} +``` + +### 2. Middleware HTTP (internal/telemetry/middleware.go) + +Instrumenta todas as requisições HTTP: + +```go +func TracingMiddleware(next http.Handler) http.Handler { + // Criação de spans para cada requisição HTTP + // Propagação de contexto + // Captura de status code e headers + // ... +} +``` + +### 3. Camada de Serviço (internal/core/task/service.go) + +Cada método de serviço cria spans próprios: + +```go +func (s *TaskService) CreateTask(title, description string, concluded bool) error { + // Criação de span específico para esta operação + // Adição de atributos e metadados + // ... +} +``` + +### 4. Repositório (internal/core/task/pg_repository.go) + +Operações de banco de dados também são rastreadas: + +```go +func (r *PgTaskRepository) GetAll() ([]Task, error) { + // Span para operação de banco + // Atributos como tipo de operação SQL + // Métricas como número de linhas retornadas + // ... +} +``` + +## Configuração do Ambiente + +O projeto usa Docker Compose para configurar: + +1. **PostgreSQL**: Banco de dados para armazenar as tarefas +2. **Jaeger**: Backend para visualização de traces + +```yaml +version: '3.8' +services: + postgres: + # Configuração do PostgreSQL + + jaeger: + # Configuração do Jaeger All-in-One + # Expõe UI na porta 16686 +``` + +## Como Executar + +1. Clone o repositório + ``` + git clone + cd todo-api + ``` + +2. Inicie os serviços + ``` + docker-compose up -d + ``` + +3. Execute as migrações + ``` + go run cmd/migrate/main.go + ``` + +4. Inicie a aplicação + ``` + go run main.go + ``` + +5. Visualize os traces em http://localhost:16686 + +## Observabilidade + +A implementação de OpenTelemetry permite: + +- **Rastreamento distribuído**: Acompanhe operações em todos os componentes +- **Métricas detalhadas**: Número de requisições, duração, erros +- **Diagnóstico de problemas**: Identifique gargalos de performance +- **Correlação de eventos**: Associe cada operação a um identificador único + +## Benefícios do OpenTelemetry + +- **Padrão aberto**: Não há lock-in de fornecedor +- **Instrumentação consistente**: Mesmo padrão em diferentes linguagens +- **Extensibilidade**: Suporte a múltiplos backends (Jaeger, Zipkin, etc.) +- **Baixo overhead**: Impacto mínimo na performance + +## Contribuindo + +Contribuições são bem-vindas! Este projeto serve como referência para implementação de telemetria em aplicações Go. diff --git a/learning-otel-go/cmd/migrate/main.go b/learning-otel-go/cmd/migrate/main.go new file mode 100644 index 0000000..fd58935 --- /dev/null +++ b/learning-otel-go/cmd/migrate/main.go @@ -0,0 +1,29 @@ +// cmd/migrate/main.go +package main + +import ( + "fmt" + "log" + "todo-api/internal/config" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" +) + +func main() { + cfg := config.LoadConfig() + + dbURL := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", cfg.Database.User, cfg.Database.Password, cfg.Database.Host, cfg.Database.Port, cfg.Database.Name) + + m, err := migrate.New("file://db/migrations", dbURL) + if err != nil { + log.Fatal(err) + } + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + log.Fatal(err) + } + + log.Println("Migrations aplicadas com sucesso!") +} diff --git a/learning-otel-go/db/migrations/000001_create_tasks_table.down.sql b/learning-otel-go/db/migrations/000001_create_tasks_table.down.sql new file mode 100644 index 0000000..2ff1380 --- /dev/null +++ b/learning-otel-go/db/migrations/000001_create_tasks_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tasks; diff --git a/learning-otel-go/db/migrations/000001_create_tasks_table.up.sql b/learning-otel-go/db/migrations/000001_create_tasks_table.up.sql new file mode 100644 index 0000000..c3e4000 --- /dev/null +++ b/learning-otel-go/db/migrations/000001_create_tasks_table.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE tasks ( + id UUID PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + concluded BOOLEAN NOT NULL DEFAULT FALSE +); \ No newline at end of file diff --git a/learning-otel-go/docker-compose.yml b/learning-otel-go/docker-compose.yml new file mode 100644 index 0000000..436c2ea --- /dev/null +++ b/learning-otel-go/docker-compose.yml @@ -0,0 +1,54 @@ +services: + postgres: + image: postgres:latest + container_name: golearn_postgres + environment: + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin123 + POSTGRES_DB: golearn_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + networks: + - otel-network + + otel-collector: + image: otel/opentelemetry-collector:latest + container_name: otel-collector + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "4319:4317" # OTLP gRPC - mapeando para 4319 externamente + - "4320:4318" # OTLP HTTP - mapeando para 4320 externamente + depends_on: + - jaeger + networks: + - otel-network + + jaeger: + image: jaegertracing/all-in-one:latest + container_name: jaeger + ports: + - "6831:6831/udp" + - "6832:6832/udp" + - "5778:5778" + - "16686:16686" + - "14250:14250" + - "14268:14268" + - "14269:14269" + - "4317:4317" # OTLP gRPC nativo do Jaeger + environment: + - COLLECTOR_OTLP_ENABLED=true + restart: unless-stopped + networks: + - otel-network + +volumes: + postgres_data: + +networks: + otel-network: + driver: bridge diff --git a/learning-otel-go/go.mod b/learning-otel-go/go.mod new file mode 100644 index 0000000..e7c5098 --- /dev/null +++ b/learning-otel-go/go.mod @@ -0,0 +1,36 @@ +module todo-api + +go 1.24.2 + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-migrate/migrate/v4 v4.18.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/lib/pq v1.10.9 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.35.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect +) diff --git a/learning-otel-go/go.sum b/learning-otel-go/go.sum new file mode 100644 index 0000000..43db076 --- /dev/null +++ b/learning-otel-go/go.sum @@ -0,0 +1,65 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= +github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/learning-otel-go/internal/app/app.go b/learning-otel-go/internal/app/app.go new file mode 100644 index 0000000..8069438 --- /dev/null +++ b/learning-otel-go/internal/app/app.go @@ -0,0 +1,189 @@ +package app + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "todo-api/internal/config" + "todo-api/internal/core/task" + "todo-api/internal/router" + "todo-api/internal/telemetry" +) + +// Application encapsula toda a lógica da aplicação +type Application struct { + config *config.Config + logger *telemetry.Logger + server *http.Server + db *sql.DB +} + +// New cria uma nova instância da aplicação +func New() *Application { + return &Application{ + config: config.LoadConfig(), + logger: telemetry.NewLogger(), + } +} + +// Initialize inicializa todos os componentes da aplicação +func (app *Application) Initialize(ctx context.Context) error { + if err := app.setupTelemetry(ctx); err != nil { + return fmt.Errorf("falha ao configurar telemetria: %w", err) + } + + if err := app.setupDatabase(ctx); err != nil { + return fmt.Errorf("falha ao configurar banco de dados: %w", err) + } + + app.setupServer(ctx) + return nil +} + +// setupTelemetry inicializa os componentes de telemetria +func (app *Application) setupTelemetry(ctx context.Context) error { + collectorURL := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + fmt.Println("collectorURL", collectorURL) + if collectorURL == "" { + collectorURL = "localhost:4317" + } + + // Inicializar tracer + traceCleanup, err := telemetry.InitTracer("todo-api", collectorURL) + if err != nil { + app.logger.Error(ctx, "Erro ao inicializar OpenTelemetry Tracer", "error", err) + return err + } + + // Registrar função de limpeza + go func() { + <-ctx.Done() + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := traceCleanup(cleanupCtx); err != nil { + app.logger.Error(cleanupCtx, "Erro ao limpar recursos de tracer", "error", err) + } + }() + + // Inicializar métricas + meterCleanup, err := telemetry.InitMeter("todo-api", collectorURL) + if err != nil { + app.logger.Error(ctx, "Erro ao inicializar OpenTelemetry Metrics", "error", err) + return err + } + + // Registrar função de limpeza + go func() { + <-ctx.Done() + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := meterCleanup(cleanupCtx); err != nil { + app.logger.Error(cleanupCtx, "Erro ao limpar recursos de métricas", "error", err) + } + }() + + return nil +} + +// setupDatabase configura a conexão com o banco de dados +func (app *Application) setupDatabase(ctx context.Context) error { + dbURL := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", + app.config.Database.User, + app.config.Database.Password, + app.config.Database.Host, + app.config.Database.Port, + app.config.Database.Name) + + db, err := sql.Open("postgres", dbURL) + if err != nil { + app.logger.Error(ctx, "Erro ao conectar no banco", "error", err) + return err + } + + if err := db.Ping(); err != nil { + app.logger.Error(ctx, "Banco indisponível", "error", err) + return err + } + + app.db = db + app.logger.Info(ctx, "Conexão com o banco de dados estabelecida com sucesso") + return nil +} + +// setupServer configura o servidor HTTP +func (app *Application) setupServer(ctx context.Context) { + repo := task.NewPgTaskRepository(app.db) + service := task.NewTaskService(repo) + r := router.NewRouter(service) + + // Adicionar middleware de telemetria + handler := telemetry.TracingMiddleware(r) + + port := app.config.Server.Port + app.server = &http.Server{ + Addr: ":" + port, + Handler: handler, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + } +} + +// Start inicia o servidor e configura o graceful shutdown +func (app *Application) Start(ctx context.Context) error { + // Contexto para gerenciar o ciclo de vida da aplicação + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Canal para receber sinais de término + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + // Iniciar o servidor em uma goroutine + errChan := make(chan error, 1) + go func() { + port := app.config.Server.Port + app.logger.Info(ctx, "Servidor iniciado", "port", port) + if err := app.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + app.logger.Error(ctx, "Erro ao iniciar servidor", "error", err) + errChan <- err + } + }() + + // Aguardar sinal de término ou erro + select { + case <-quit: + app.logger.Info(ctx, "Desligando servidor...") + case err := <-errChan: + app.logger.Error(ctx, "Erro fatal no servidor", "error", err) + return err + } + + return app.Shutdown(ctx) +} + +// Shutdown desliga o servidor de forma graciosa +func (app *Application) Shutdown(ctx context.Context) error { + shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if err := app.server.Shutdown(shutdownCtx); err != nil { + app.logger.Error(shutdownCtx, "Erro durante shutdown do servidor", "error", err) + return err + } + + if app.db != nil { + if err := app.db.Close(); err != nil { + app.logger.Error(shutdownCtx, "Erro ao fechar conexão com banco de dados", "error", err) + return err + } + } + + app.logger.Info(shutdownCtx, "Servidor desligado com sucesso") + return nil +} diff --git a/learning-otel-go/internal/config/env.go b/learning-otel-go/internal/config/env.go new file mode 100644 index 0000000..d0bfa34 --- /dev/null +++ b/learning-otel-go/internal/config/env.go @@ -0,0 +1,69 @@ +package config + +import ( + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +// LoadEnv carrega as variáveis de ambiente do arquivo .env, se existir +func LoadEnv() { + // Tenta carregar .env, mas não falha se o arquivo não existir + if err := godotenv.Load(); err != nil { + log.Println("Arquivo .env não encontrado, usando variáveis de ambiente do sistema") + } +} + +type Config struct { + Server ServerConfig + Database DatabaseConfig + LogLevel string + APIKey string +} + +type ServerConfig struct { + Port string + Env string +} + +type DatabaseConfig struct { + Host string + Port int + User string + Password string + Name string +} + +func LoadConfig() *Config { + // Tenta carregar as variáveis de ambiente do arquivo .env + LoadEnv() + + port := getEnv("PORT", "8080") + dbPort, _ := strconv.Atoi(getEnv("DB_PORT", "5432")) + + return &Config{ + Server: ServerConfig{ + Port: port, + Env: getEnv("ENV", "development"), + }, + Database: DatabaseConfig{ + Host: getEnv("DB_HOST", "localhost"), + Port: dbPort, + User: getEnv("DB_USER", "admin"), + Password: getEnv("DB_PASS", "admin123"), + Name: getEnv("DB_NAME", "golearn_db"), + }, + LogLevel: getEnv("LOG_LEVEL", "info"), + APIKey: getEnv("API_KEY", ""), + } +} + +func getEnv(key, defaultValue string) string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return value +} diff --git a/learning-otel-go/internal/core/task/mock_repository.go b/learning-otel-go/internal/core/task/mock_repository.go new file mode 100644 index 0000000..ca7cd14 --- /dev/null +++ b/learning-otel-go/internal/core/task/mock_repository.go @@ -0,0 +1,43 @@ +package task + +type MockTaskRepository struct { + tasks []Task + created []Task +} + +func NewMockTaskRepository() *MockTaskRepository { + return &MockTaskRepository{ + tasks: []Task{}, + created: []Task{}, + } +} + +func (m *MockTaskRepository) Create(t Task) error { + m.tasks = append(m.tasks, t) + m.created = append(m.created, t) + return nil +} + +func (m *MockTaskRepository) GetAll() ([]Task, error) { + return m.tasks, nil +} + +func (m *MockTaskRepository) Update(id string, newTask Task) error { + for i, t := range m.tasks { + if t.Id == id { + m.tasks[i] = newTask + return nil + } + } + return nil +} + +func (m *MockTaskRepository) Delete(id string) error { + for i, t := range m.tasks { + if t.Id == id { + m.tasks = append(m.tasks[:i], m.tasks[i+1:]...) + return nil + } + } + return nil +} diff --git a/learning-otel-go/internal/core/task/model.go b/learning-otel-go/internal/core/task/model.go new file mode 100644 index 0000000..006807b --- /dev/null +++ b/learning-otel-go/internal/core/task/model.go @@ -0,0 +1,8 @@ +package task + +type Task struct { + Id string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Concluded bool `json:"concluded"` +} diff --git a/learning-otel-go/internal/core/task/pg_repository.go b/learning-otel-go/internal/core/task/pg_repository.go new file mode 100644 index 0000000..9c7daa5 --- /dev/null +++ b/learning-otel-go/internal/core/task/pg_repository.go @@ -0,0 +1,113 @@ +package task + +import ( + "context" + "database/sql" + "errors" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type PgTaskRepository struct { + db *sql.DB +} + +func NewPgTaskRepository(db *sql.DB) *PgTaskRepository { + return &PgTaskRepository{db: db} +} + +func (r *PgTaskRepository) Create(task Task) error { + ctx := context.Background() + tracer := otel.Tracer("postgres-repository") + + ctx, span := tracer.Start(ctx, "CreateTask", trace.WithAttributes( + attribute.String("db.operation", "INSERT"), + attribute.String("task.id", task.Id), + )) + defer span.End() + + query := `INSERT INTO tasks (id, title, description, concluded) VALUES ($1, $2, $3, $4)` + _, err := r.db.ExecContext(ctx, query, task.Id, task.Title, task.Description, task.Concluded) + return err +} + +func (r *PgTaskRepository) GetAll() ([]Task, error) { + ctx := context.Background() + tracer := otel.Tracer("postgres-repository") + + ctx, span := tracer.Start(ctx, "GetAllTasks", trace.WithAttributes( + attribute.String("db.operation", "SELECT"), + )) + defer span.End() + + query := `SELECT id, title, description, concluded FROM tasks` + rows, err := r.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var tasks []Task + for rows.Next() { + var t Task + if err := rows.Scan(&t.Id, &t.Title, &t.Description, &t.Concluded); err != nil { + return nil, err + } + tasks = append(tasks, t) + } + + span.SetAttributes(attribute.Int("db.rows_returned", len(tasks))) + return tasks, nil +} + +func (r *PgTaskRepository) Update(id string, task Task) error { + ctx := context.Background() + tracer := otel.Tracer("postgres-repository") + + ctx, span := tracer.Start(ctx, "UpdateTask", trace.WithAttributes( + attribute.String("db.operation", "UPDATE"), + attribute.String("task.id", id), + )) + defer span.End() + + query := `UPDATE tasks SET title = $1, description = $2, concluded = $3 WHERE id = $4` + result, err := r.db.ExecContext(ctx, query, task.Title, task.Description, task.Concluded, id) + if err != nil { + return err + } + + rowsAffected, _ := result.RowsAffected() + span.SetAttributes(attribute.Int("db.rows_affected", int(rowsAffected))) + + if rowsAffected == 0 { + return errors.New("task não encontrada") + } + return nil +} + +func (r *PgTaskRepository) Delete(id string) error { + ctx := context.Background() + tracer := otel.Tracer("postgres-repository") + + ctx, span := tracer.Start(ctx, "DeleteTask", trace.WithAttributes( + attribute.String("db.operation", "DELETE"), + attribute.String("task.id", id), + )) + defer span.End() + + query := `DELETE FROM tasks WHERE id = $1` + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return err + } + + rowsAffected, _ := result.RowsAffected() + span.SetAttributes(attribute.Int("db.rows_affected", int(rowsAffected))) + + if rowsAffected == 0 { + return errors.New("task não encontrada") + } + return nil +} diff --git a/learning-otel-go/internal/core/task/repository.go b/learning-otel-go/internal/core/task/repository.go new file mode 100644 index 0000000..2c8ef70 --- /dev/null +++ b/learning-otel-go/internal/core/task/repository.go @@ -0,0 +1,8 @@ +package task + +type TaskRepository interface { + Create(task Task) error + GetAll() ([]Task, error) + Update(id string, task Task) error + Delete(id string) error +} diff --git a/learning-otel-go/internal/core/task/service.go b/learning-otel-go/internal/core/task/service.go new file mode 100644 index 0000000..3043b5f --- /dev/null +++ b/learning-otel-go/internal/core/task/service.go @@ -0,0 +1,83 @@ +package task + +import ( + "context" + + "github.com/google/uuid" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type TaskService struct { + repo TaskRepository +} + +func NewTaskService(repo TaskRepository) *TaskService { + return &TaskService{repo: repo} +} + +func (s *TaskService) CreateTask(title, description string, concluded bool) error { + // Obter o tracer para este service + ctx := context.Background() + tracer := otel.Tracer("task-service") + + // Criar um span para a operação + ctx, span := tracer.Start(ctx, "CreateTask", trace.WithAttributes( + attribute.String("task.title", title), + )) + defer span.End() + + id := uuid.New().String() + task := Task{ + Id: id, + Title: title, + Description: description, + Concluded: concluded, + } + + // Adicionar ID ao span para correlacionar + span.SetAttributes(attribute.String("task.id", id)) + + return s.repo.Create(task) +} + +func (s *TaskService) GetTasks() ([]Task, error) { + ctx := context.Background() + tracer := otel.Tracer("task-service") + + ctx, span := tracer.Start(ctx, "GetTasks") + defer span.End() + + tasks, err := s.repo.GetAll() + if err != nil { + return nil, err + } + + span.SetAttributes(attribute.Int("task.count", len(tasks))) + return tasks, nil +} + +func (s *TaskService) EditTask(id string, newTask Task) error { + ctx := context.Background() + tracer := otel.Tracer("task-service") + + ctx, span := tracer.Start(ctx, "EditTask", trace.WithAttributes( + attribute.String("task.id", id), + )) + defer span.End() + + return s.repo.Update(id, newTask) +} + +func (s *TaskService) DeleteTask(id string) error { + ctx := context.Background() + tracer := otel.Tracer("task-service") + + ctx, span := tracer.Start(ctx, "DeleteTask", trace.WithAttributes( + attribute.String("task.id", id), + )) + defer span.End() + + return s.repo.Delete(id) +} diff --git a/learning-otel-go/internal/core/task/service_test.go b/learning-otel-go/internal/core/task/service_test.go new file mode 100644 index 0000000..1a65700 --- /dev/null +++ b/learning-otel-go/internal/core/task/service_test.go @@ -0,0 +1,57 @@ +package task + +import ( + "testing" +) + +func TestCreateTask(t *testing.T) { + mockRepo := NewMockTaskRepository() + service := NewTaskService(mockRepo) + + err := service.CreateTask("Título", "Descrição", false) + if err != nil { + t.Errorf("Erro inesperado ao criar tarefa: %v", err) + } + + tasks, _ := mockRepo.GetAll() + if len(tasks) != 1 { + t.Errorf("Esperava 1 tarefa, mas tenho %d", len(tasks)) + } +} + +func TestEditTask(t *testing.T) { + + mockRepo := NewMockTaskRepository() + service := NewTaskService(mockRepo) + + service.CreateTask("Antigo", "Desc", false) + + tasks, _ := mockRepo.GetAll() + if len(tasks) != 1 { + t.Fatalf("Falha ao criar tarefa inicial") + } + taskId := tasks[0].Id + + update := Task{Title: "Novo", Description: "Nova Desc", Concluded: true} + service.EditTask(taskId, update) + + tasksAtualizadas, _ := mockRepo.GetAll() + if tasksAtualizadas[0].Title != "Novo" { + t.Errorf("Esperava título 'Novo', obtive '%s'", tasksAtualizadas[0].Title) + } +} + +func TestDeleteTask(t *testing.T) { + mockRepo := NewMockTaskRepository() + service := NewTaskService(mockRepo) + + task := Task{Id: "123", Title: "Excluir", Description: "Desc", Concluded: false} + mockRepo.tasks = append(mockRepo.tasks, task) + + service.DeleteTask("123") + + tasks, _ := mockRepo.GetAll() + if len(tasks) != 0 { + t.Errorf("Esperava lista vazia, mas tenho %d item(ns)", len(tasks)) + } +} diff --git a/learning-otel-go/internal/handler/task_handler.go b/learning-otel-go/internal/handler/task_handler.go new file mode 100644 index 0000000..3df1b7d --- /dev/null +++ b/learning-otel-go/internal/handler/task_handler.go @@ -0,0 +1,78 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "todo-api/internal/core/task" + + "github.com/gorilla/mux" +) + +type TaskHandler struct { + service *task.TaskService +} + +func NewTaskHandler(service *task.TaskService) *TaskHandler { + return &TaskHandler{service: service} +} + +func (h *TaskHandler) CreateTaskHandler(w http.ResponseWriter, r *http.Request) { + var taskBody task.Task + err := json.NewDecoder(r.Body).Decode(&taskBody) + if err != nil { + http.Error(w, "Erro ao decodificar JSON", http.StatusBadRequest) + return + } + + err = h.service.CreateTask(taskBody.Title, taskBody.Description, taskBody.Concluded) + if err != nil { + http.Error(w, "Erro ao criar tarefa", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (h *TaskHandler) GetTasksHandler(w http.ResponseWriter, r *http.Request) { + tasks, err := h.service.GetTasks() + if err != nil { + http.Error(w, "Erro ao buscar tarefas", http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(tasks) +} + +func (h *TaskHandler) EditTaskHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + var taskBody task.Task + err := json.NewDecoder(r.Body).Decode(&taskBody) + if err != nil { + http.Error(w, "Erro ao decodificar JSON", http.StatusBadRequest) + return + } + + err = h.service.EditTask(id, taskBody) + if err != nil { + http.Error(w, "Erro ao editar tarefa", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *TaskHandler) DeleteTaskHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + err := h.service.DeleteTask(id) + if err != nil { + http.Error(w, "Erro ao deletar tarefa", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/learning-otel-go/internal/handler/task_handler_test.go b/learning-otel-go/internal/handler/task_handler_test.go new file mode 100644 index 0000000..5a3435c --- /dev/null +++ b/learning-otel-go/internal/handler/task_handler_test.go @@ -0,0 +1,126 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "todo-api/internal/core/task" + + "github.com/gorilla/mux" +) + +// Função para simular variáveis de rota do Mux +func muxSetVars(r *http.Request, vars map[string]string) *http.Request { + return mux.SetURLVars(r, vars) +} + +func setupMockHandler() *TaskHandler { + mockRepo := task.NewMockTaskRepository() + service := task.NewTaskService(mockRepo) + + service.CreateTask("Teste", "Descrição", false) + + return NewTaskHandler(service) +} + +func TestCreateTaskHandler(t *testing.T) { + handler := setupMockHandler() + + body := map[string]interface{}{ + "title": "Nova Task", + "description": "Descrição da nova task", + "concluded": false, + } + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest("POST", "/task", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler.CreateTaskHandler(rr, req) + + if status := rr.Code; status != http.StatusCreated { + t.Errorf("Esperava status 201, obtive %d", status) + } +} + +func TestGetTasksHandler(t *testing.T) { + handler := setupMockHandler() + + req := httptest.NewRequest("GET", "/tasks", nil) + rr := httptest.NewRecorder() + + handler.GetTasksHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Esperava 200, obtive %d", rr.Code) + } + + var tasks []task.Task + err := json.NewDecoder(rr.Body).Decode(&tasks) + if err != nil { + t.Errorf("Erro ao decodificar JSON: %v", err) + } + + if len(tasks) == 0 { + t.Errorf("Esperava ao menos 1 tarefa") + } +} + +func TestEditTaskHandler(t *testing.T) { + mockRepo := task.NewMockTaskRepository() + svc := task.NewTaskService(mockRepo) + handler := NewTaskHandler(svc) + + taskData := task.Task{ + Id: "123", + Title: "Antigo", + Description: "Desc", + Concluded: false, + } + mockRepo.Create(taskData) + + updated := task.Task{ + Title: "Atualizado", + Description: "Nova Desc", + Concluded: true, + } + body, _ := json.Marshal(updated) + + req := httptest.NewRequest("PUT", "/task/123", bytes.NewBuffer(body)) + req = muxSetVars(req, map[string]string{"id": "123"}) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler.EditTaskHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Esperava 200, obtive %d", rr.Code) + } +} + +func TestDeleteTaskHandler(t *testing.T) { + mockRepo := task.NewMockTaskRepository() + svc := task.NewTaskService(mockRepo) + handler := NewTaskHandler(svc) + + mockRepo.Create(task.Task{ + Id: "abc", + Title: "Apagar", + Description: "Desc", + Concluded: false, + }) + + req := httptest.NewRequest("DELETE", "/task/abc", nil) + req = muxSetVars(req, map[string]string{"id": "abc"}) + rr := httptest.NewRecorder() + + handler.DeleteTaskHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Esperava 200, obtive %d", rr.Code) + } +} diff --git a/learning-otel-go/internal/router/router.go b/learning-otel-go/internal/router/router.go new file mode 100644 index 0000000..f83287e --- /dev/null +++ b/learning-otel-go/internal/router/router.go @@ -0,0 +1,30 @@ +package router + +import ( + "net/http" + + "todo-api/internal/core/task" + "todo-api/internal/handler" + + "github.com/gorilla/mux" +) + +func NewRouter(service *task.TaskService) http.Handler { + r := mux.NewRouter() + + taskHandler := handler.NewTaskHandler(service) + + // Configurar rotas + r.HandleFunc("/task", taskHandler.CreateTaskHandler).Methods("POST") + r.HandleFunc("/tasks", taskHandler.GetTasksHandler).Methods("GET") + r.HandleFunc("/task/{id}", taskHandler.EditTaskHandler).Methods("PUT") + r.HandleFunc("/task/{id}", taskHandler.DeleteTaskHandler).Methods("DELETE") + + // Adicionar health check + r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }).Methods("GET") + + return r +} diff --git a/learning-otel-go/internal/telemetry/logging.go b/learning-otel-go/internal/telemetry/logging.go new file mode 100644 index 0000000..4f0877a --- /dev/null +++ b/learning-otel-go/internal/telemetry/logging.go @@ -0,0 +1,98 @@ +package telemetry + +import ( + "context" + "fmt" + "log" + "os" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// Logger é uma estrutura personalizada para logs que integra com OpenTelemetry +type Logger struct { + infoLogger *log.Logger + errorLogger *log.Logger + debugLogger *log.Logger +} + +// NewLogger cria uma nova instância de Logger +func NewLogger() *Logger { + return &Logger{ + infoLogger: log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile), + errorLogger: log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile), + debugLogger: log.New(os.Stdout, "DEBUG: ", log.Ldate|log.Ltime|log.Lshortfile), + } +} + +// Info registra uma mensagem de informação +func (l *Logger) Info(ctx context.Context, msg string, keyValues ...interface{}) { + // Adiciona informações de trace se disponíveis + addTraceInfoToLog(ctx, l.infoLogger, msg, keyValues...) +} + +// Error registra uma mensagem de erro +func (l *Logger) Error(ctx context.Context, msg string, keyValues ...interface{}) { + // Marca o span atual com o erro (se existir) + span := trace.SpanFromContext(ctx) + if span.IsRecording() { + span.SetStatus(codes.Error, msg) + + // Adiciona atributos do erro ao span + for i := 0; i < len(keyValues); i += 2 { + if i+1 < len(keyValues) { + key, ok := keyValues[i].(string) + if ok { + span.SetAttributes(attribute.String(key, toString(keyValues[i+1]))) + } + } + } + } + + // Registra no log + addTraceInfoToLog(ctx, l.errorLogger, msg, keyValues...) +} + +// Debug registra uma mensagem de depuração +func (l *Logger) Debug(ctx context.Context, msg string, keyValues ...interface{}) { + // Adiciona informações de trace se disponíveis + addTraceInfoToLog(ctx, l.debugLogger, msg, keyValues...) +} + +// addTraceInfoToLog adiciona informações de trace ao log +func addTraceInfoToLog(ctx context.Context, logger *log.Logger, msg string, keyValues ...interface{}) { + // Extrai o TraceID e SpanID do contexto, se estiver disponível + span := trace.SpanFromContext(ctx) + + if span.IsRecording() { + spanContext := span.SpanContext() + if spanContext.IsValid() { + // Adiciona os IDs de trace e span à mensagem + msg = msg + " [trace_id=" + spanContext.TraceID().String() + + " span_id=" + spanContext.SpanID().String() + "]" + } + } + + // Formata os pares chave-valor adicionais + if len(keyValues) > 0 { + pairs := make([]interface{}, 0, len(keyValues)+1) + pairs = append(pairs, msg) + pairs = append(pairs, keyValues...) + logger.Println(pairs...) + } else { + logger.Println(msg) + } +} + +// toString converte um valor para string +func toString(value interface{}) string { + if value == nil { + return "" + } + if s, ok := value.(string); ok { + return s + } + return fmt.Sprintf("%v", value) +} diff --git a/learning-otel-go/internal/telemetry/metrics.go b/learning-otel-go/internal/telemetry/metrics.go new file mode 100644 index 0000000..aa2f26d --- /dev/null +++ b/learning-otel-go/internal/telemetry/metrics.go @@ -0,0 +1,169 @@ +package telemetry + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/sdk/resource" + + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" +) + +// Variáveis globais de métricas para uso em todo o aplicativo +var ( + // Contador para requisições HTTP + httpRequestCounter metric.Int64Counter + // Histograma para duração de requisições + httpRequestDuration metric.Float64Histogram + // Contador para operações de banco de dados + dbOperationCounter metric.Int64Counter + // Histograma para duração de operações de banco de dados + dbOperationDuration metric.Float64Histogram +) + +// InitMeter inicializa o provedor de métricas do OpenTelemetry +func InitMeter(serviceName string, collectorURL string) (func(context.Context) error, error) { + ctx := context.Background() + + // Cria um recurso que identifica a aplicação + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(serviceName), + ), + ) + if err != nil { + return nil, fmt.Errorf("falha ao criar recurso: %w", err) + } + + // Cria o exportador de métricas usando gRPC com o mesmo coletor dos traces + exporter, err := otlpmetricgrpc.New(ctx, + otlpmetricgrpc.WithEndpoint(collectorURL), + otlpmetricgrpc.WithInsecure(), + ) + if err != nil { + return nil, fmt.Errorf("falha ao criar exportador de métricas: %w", err) + } + + // Cria o leitor periódico de métricas que envia dados a cada 15 segundos + reader := sdkmetric.NewPeriodicReader(exporter, + sdkmetric.WithInterval(15*time.Second), + ) + + // Cria um provedor de métricas + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithResource(res), + sdkmetric.WithReader(reader), + ) + + // Define o provedor global de métricas + otel.SetMeterProvider(meterProvider) + + // Cria um medidor de métricas específico para nossa aplicação + meter := meterProvider.Meter( + "todo-api", + metric.WithInstrumentationVersion("0.1.0"), + ) + + // Inicializa as métricas que serão usadas pelo aplicativo + initMetrics(meter) + + // Função para limpeza ao encerrar a aplicação + cleanup := func(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := meterProvider.Shutdown(ctx); err != nil { + return fmt.Errorf("falha ao desligar provedor de métricas: %w", err) + } + return nil + } + + return cleanup, nil +} + +// Inicializa as métricas que serão usadas pelo aplicativo +func initMetrics(meter metric.Meter) { + var err error + + // Contador de requisições HTTP + httpRequestCounter, err = meter.Int64Counter( + "http.requests.total", + metric.WithDescription("Número total de requisições HTTP"), + metric.WithUnit("1"), + ) + if err != nil { + otel.Handle(err) + } + + // Histograma de duração de requisições HTTP + httpRequestDuration, err = meter.Float64Histogram( + "http.request.duration", + metric.WithDescription("Duração das requisições HTTP"), + metric.WithUnit("ms"), + ) + if err != nil { + otel.Handle(err) + } + + // Contador de operações de banco de dados + dbOperationCounter, err = meter.Int64Counter( + "db.operations.total", + metric.WithDescription("Número total de operações de banco de dados"), + metric.WithUnit("1"), + ) + if err != nil { + otel.Handle(err) + } + + // Histograma de duração de operações de banco de dados + dbOperationDuration, err = meter.Float64Histogram( + "db.operation.duration", + metric.WithDescription("Duração das operações de banco de dados"), + metric.WithUnit("ms"), + ) + if err != nil { + otel.Handle(err) + } +} + +// RecordHTTPRequest registra uma requisição HTTP +func RecordHTTPRequest(ctx context.Context, method, route string, statusCode int, duration float64) { + httpRequestCounter.Add(ctx, 1, + metric.WithAttributes( + semconv.HTTPMethodKey.String(method), + semconv.HTTPRouteKey.String(route), + semconv.HTTPStatusCodeKey.Int(statusCode), + ), + ) + + httpRequestDuration.Record(ctx, duration, + metric.WithAttributes( + semconv.HTTPMethodKey.String(method), + semconv.HTTPRouteKey.String(route), + semconv.HTTPStatusCodeKey.Int(statusCode), + ), + ) +} + +// RecordDBOperation registra uma operação de banco de dados +func RecordDBOperation(ctx context.Context, operation string, success bool, duration float64) { + dbOperationCounter.Add(ctx, 1, + metric.WithAttributes( + semconv.DBOperationKey.String(operation), + semconv.DBSystemKey.String("postgresql"), + semconv.DBStatementKey.String(operation), + ), + ) + + dbOperationDuration.Record(ctx, duration, + metric.WithAttributes( + semconv.DBOperationKey.String(operation), + semconv.DBSystemKey.String("postgresql"), + semconv.DBStatementKey.String(operation), + ), + ) +} diff --git a/learning-otel-go/internal/telemetry/middleware.go b/learning-otel-go/internal/telemetry/middleware.go new file mode 100644 index 0000000..e858d97 --- /dev/null +++ b/learning-otel-go/internal/telemetry/middleware.go @@ -0,0 +1,109 @@ +package telemetry + +import ( + "net/http" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +// TracingMiddleware instrumenta requisições HTTP com OpenTelemetry +func TracingMiddleware(next http.Handler) http.Handler { + tracer := otel.Tracer("http-middleware") + logger := NewLogger() + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Marca o início para cálculo de duração + startTime := time.Now() + + // Extrai contexto de trace da requisição + propagator := otel.GetTextMapPropagator() + ctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header)) + + // Inicia um novo span para esta requisição + path := r.URL.Path + method := r.Method + ctx, span := tracer.Start( + ctx, + method+" "+path, + trace.WithSpanKind(trace.SpanKindServer), + trace.WithAttributes( + attribute.String("http.method", method), + attribute.String("http.url", path), + attribute.String("http.user_agent", r.UserAgent()), + attribute.String("http.remote_addr", r.RemoteAddr), + ), + ) + defer span.End() + + // Log da requisição recebida + logger.Info(ctx, "Requisição recebida", + "method", method, + "path", path, + "remote_addr", r.RemoteAddr, + "user_agent", r.UserAgent(), + ) + + // Wrapper do ResponseWriter para capturar o código de status + wrapper := &responseWriter{w: w, status: http.StatusOK} + + // Passa o contexto de trace para o próximo handler + next.ServeHTTP(wrapper, r.WithContext(ctx)) + + // Calcula a duração + duration := float64(time.Since(startTime).Milliseconds()) + + // Adiciona informações de resposta ao span + span.SetAttributes( + attribute.Int("http.status_code", wrapper.status), + attribute.Float64("http.duration_ms", duration), + ) + + // Registra métricas (comentado até os pacotes de métricas serem adicionados) + /* + RecordHTTPRequest(ctx, method, path, wrapper.status, duration) + */ + + // Log da resposta + if wrapper.status >= 400 { + logger.Error(ctx, "Erro na resposta HTTP", + "method", method, + "path", path, + "status", wrapper.status, + "duration_ms", duration, + ) + span.SetStatus(codes.Error, http.StatusText(wrapper.status)) + } else { + logger.Info(ctx, "Resposta enviada com sucesso", + "method", method, + "path", path, + "status", wrapper.status, + "duration_ms", duration, + ) + span.SetStatus(codes.Ok, "") + } + }) +} + +// responseWriter é um wrapper de http.ResponseWriter que captura o código de status +type responseWriter struct { + w http.ResponseWriter + status int +} + +func (rw *responseWriter) Header() http.Header { + return rw.w.Header() +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + return rw.w.Write(b) +} + +func (rw *responseWriter) WriteHeader(statusCode int) { + rw.status = statusCode + rw.w.WriteHeader(statusCode) +} diff --git a/learning-otel-go/internal/telemetry/tracing.go b/learning-otel-go/internal/telemetry/tracing.go new file mode 100644 index 0000000..be6fc78 --- /dev/null +++ b/learning-otel-go/internal/telemetry/tracing.go @@ -0,0 +1,66 @@ +package telemetry + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" +) + +// InitTracer inicializa o rastreamento do OpenTelemetry +func InitTracer(serviceName string, collectorURL string) (func(context.Context) error, error) { + ctx := context.Background() + + // Cria o exportador de traces usando a API mais recente + traceExporter, err := otlptracegrpc.New(ctx, + otlptracegrpc.WithEndpoint(collectorURL), + otlptracegrpc.WithInsecure(), + ) + if err != nil { + return nil, fmt.Errorf("falha ao criar exportador OTLP: %w", err) + } + + // Cria um recurso que identifica a aplicação + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(serviceName), + ), + ) + if err != nil { + return nil, fmt.Errorf("falha ao criar recurso: %w", err) + } + + // Cria um provedor de tracer + tracerProvider := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(traceExporter), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.AlwaysSample()), + ) + + // Define o provedor global de tracer + otel.SetTracerProvider(tracerProvider) + + // Define o propagador global + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + // Função para limpeza ao encerrar a aplicação + cleanup := func(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := tracerProvider.Shutdown(ctx); err != nil { + return fmt.Errorf("falha ao desligar provedor de tracer: %w", err) + } + return nil + } + + return cleanup, nil +} diff --git a/learning-otel-go/main.go b/learning-otel-go/main.go new file mode 100644 index 0000000..19b7644 --- /dev/null +++ b/learning-otel-go/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "log" + "os" + + _ "github.com/lib/pq" + + "todo-api/internal/app" +) + +func main() { + // Criar contexto raiz da aplicação + ctx := context.Background() + + // Criar e inicializar a aplicação + application := app.New() + + if err := application.Initialize(ctx); err != nil { + log.Fatalf("Falha ao inicializar aplicação: %v", err) + os.Exit(1) + } + + // Iniciar o servidor + if err := application.Start(ctx); err != nil { + log.Fatalf("Erro fatal na aplicação: %v", err) + os.Exit(1) + } +} diff --git a/learning-otel-go/otel-collector-config.yaml b/learning-otel-go/otel-collector-config.yaml new file mode 100644 index 0000000..f8a8335 --- /dev/null +++ b/learning-otel-go/otel-collector-config.yaml @@ -0,0 +1,34 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + http: + endpoint: "0.0.0.0:4318" + +processors: + batch: + timeout: 1s + send_batch_size: 1024 + +exporters: + otlp: + endpoint: "jaeger:4317" + tls: + insecure: true + debug: + verbosity: detailed + +service: + telemetry: + logs: + level: "debug" + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp, debug] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [debug] # Você pode adicionar Prometheus aqui