diff --git a/.env.example b/.env.example index d6b68b1..c02d307 100644 --- a/.env.example +++ b/.env.example @@ -1,52 +1,35 @@ -# FACEIT API Key -# Get your API key from: https://developers.faceit.com/ -FACEIT_API_KEY=your_api_key_here +# FACEIT API Configuration +FACEIT_API_KEY=your_faceit_api_key_here -# Default player nickname (optional) -# If set, this player will be loaded automatically on startup -FACEIT_DEFAULT_PLAYER=your_nickname_here +# Optional: Default player nickname +FACEIT_DEFAULT_PLAYER= -# Logging configuration -# Log level: debug, info, warn, error (default: info) +# Logging Configuration LOG_LEVEL=info +LOG_TO_STDOUT=true +PRODUCTION_MODE=false -# Kafka configuration -# Enable Kafka logging (default: false) +# Kafka Configuration (optional) KAFKA_ENABLED=false - -# Kafka brokers (comma-separated, default: localhost:9092) KAFKA_BROKERS=localhost:9092 - -# Kafka topic for logs (default: faceit-cli-logs) KAFKA_TOPIC=faceit-cli-logs -# Production mode settings -# Enable production mode (default: false) -PRODUCTION_MODE=false - -# Log to stdout (default: true, set to false in production) -LOG_TO_STDOUT=true - -# Pagination settings -# Matches per page (default: 10) +# Pagination Configuration MATCHES_PER_PAGE=10 - -# Maximum matches to load (default: 100) MAX_MATCHES_TO_LOAD=100 +COMPARISON_MATCHES=20 -# Cache settings -# Enable caching (default: false) +# Cache Configuration CACHE_ENABLED=false - -# Cache TTL in minutes (default: 30) CACHE_TTL=30 -# Comparison settings -# Number of matches to use for player comparison (default: 20) -COMPARISON_MATCHES=20 +# Telemetry Configuration +TELEMETRY_ENABLED=true +OTLP_ENDPOINT=http://localhost:4318 +ZIPKIN_ENDPOINT=http://localhost:9411/api/v2/spans +SERVICE_NAME=faceit-cli +SERVICE_VERSION=1.0.0 +ENVIRONMENT=development -# Match Search Features -# - Search matches by ID from main menu (press '2') -# - Paste match IDs with Ctrl+V, Cmd+V, F2, or P -# - View detailed team statistics for any match -# - Cross-platform clipboard support (macOS, Linux, Windows) +# Suppress OpenTelemetry logs to avoid stdout pollution +OTEL_LOG_LEVEL=info \ No newline at end of file diff --git a/.gitignore b/.gitignore index a496ee1..af6c854 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ Thumbs.db # Coverage coverage.out coverage.html + +/bazel-* +WARP.md diff --git a/Makefile b/Makefile index d1181ed..9b7c191 100644 --- a/Makefile +++ b/Makefile @@ -196,6 +196,18 @@ run-optimized: @echo "Running with all optimizations..." CACHE_ENABLED=true CACHE_TTL=30 MATCHES_PER_PAGE=10 MAX_MATCHES_TO_LOAD=50 go run main.go +# Initialize configuration +.PHONY: init +init: + @echo "Initializing configuration..." + @go run main.go init + +# Install via go install +.PHONY: install +install: + @echo "Installing faceit-cli via go install..." + @go install github.com/armitageee/faceit-cli@latest + # Help .PHONY: help help: @@ -213,6 +225,8 @@ help: @echo " clean - Clean build artifacts" @echo " deps - Install dependencies" @echo " install-tools - Install development tools" + @echo " init - Initialize configuration file" + @echo " install - Install via go install" @echo " docker-build - Build Docker image" @echo " docker-run - Run in Docker with .env file" @echo " run - Build and run the application" diff --git a/README.md b/README.md index 41f113e..61cff2a 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,27 @@ A beautiful terminal user interface (TUI) for viewing FACEIT player profiles and ## Quick Start -### Option 1: Pre-built Binaries +### Option 1: Go Install (Recommended) + +```bash +# Install the latest version +go install github.com/armitageee/faceit-cli@latest + +# Initialize configuration +faceit-cli init + +# Edit the config file and add your API key +# ~/.config/faceit-cli/config.yml + +# Run the application +faceit-cli +``` + +### Option 2: Pre-built Binaries Download the latest release from the [Releases page](https://github.com/armitageee/faceit-cli/releases) and extract the binary for your platform. -### Option 2: Docker +### Option 3: Docker ```bash # Clone the repository @@ -80,7 +96,45 @@ make run-production ## Configuration -Create a `.env` file in the project root: +### Configuration Priority + +The application uses a flexible configuration system with the following priority order: + +1. **Environment Variables** (highest priority) +2. **YAML Configuration** (`~/.config/faceit-cli/config.yml`) +3. **Default Values** (lowest priority) + +### YAML Configuration + +The application supports YAML configuration files for better user experience: + +```bash +# Initialize default config +faceit-cli init + +# Edit the config file +# ~/.config/faceit-cli/config.yml +``` + +### Environment Variables Override + +Environment variables always take priority over YAML configuration. This is useful for: +- Production deployments +- CI/CD pipelines +- Temporary overrides +- Security-sensitive values + +**Example:** +```bash +# YAML config has: log_level: "info", cache_enabled: true +# Environment variable overrides it: +export LOG_LEVEL="debug" +export CACHE_ENABLED="false" + +# Result: log_level="debug", cache_enabled=false +``` + +You can use environment variables or a `.env` file in the project root: ```bash # Required diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..c393af8 --- /dev/null +++ b/config.example.yml @@ -0,0 +1,35 @@ +# FACEIT CLI Configuration +# Copy this file to ~/.config/faceit-cli/config.yml and configure your settings + +# Required: Your FACEIT API key +api_key: "your_faceit_api_key_here" + +# Optional: Default player to load on startup +default_player: "" + +# Logging settings +log_level: "info" # debug, info, warn, error +log_to_stdout: false +production_mode: false + +# Pagination settings +matches_per_page: 10 +max_matches_to_load: 100 +comparison_matches: 20 + +# Caching settings +cache_enabled: true +cache_ttl: 30 # minutes + +# Kafka integration (optional) +kafka_enabled: false +kafka_brokers: "localhost:9092" +kafka_topic: "faceit-cli-logs" + +# Telemetry settings (optional) +telemetry_enabled: false +otlp_endpoint: "localhost:4317" +service_name: "faceit-cli" +service_version: "1.0.0" +environment: "development" +otel_log_level: "fatal" diff --git a/docker-compose.yml b/docker-compose.yml index fb3ee51..f77252e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,5 +67,51 @@ services: " restart: "no" + # OpenTelemetry Collector + otel-collector: + image: otel/opentelemetry-collector-contrib:0.95.0 + container_name: faceit-cli-otel-collector + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "4317:4317" # OTLP gRPC receiver + - "4318:4318" # OTLP HTTP receiver + - "8888:8888" # Prometheus metrics + - "8889:8889" # Prometheus exporter metrics + depends_on: + - zipkin + environment: + - OTEL_RESOURCE_ATTRIBUTES=service.name=faceit-cli-collector,service.version=1.0.0 + + # Zipkin for trace visualization + zipkin: + image: openzipkin/zipkin:3.3 + container_name: faceit-cli-zipkin + ports: + - "9411:9411" + environment: + - STORAGE_TYPE=mem + - MYSQL_HOST=zipkin-mysql + - MYSQL_USER=zipkin + - MYSQL_PASS=zipkin + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:9411/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Jaeger as alternative trace backend (optional) + # jaeger: + # image: jaegertracing/all-in-one:1.55 + # container_name: faceit-cli-jaeger + # ports: + # - "16686:16686" # Jaeger UI + # - "14250:14250" # Jaeger gRPC + # environment: + # - COLLECTOR_OTLP_ENABLED=true + # profiles: + # - jaeger + volumes: kafka-data: \ No newline at end of file diff --git a/DOCKER.md b/docs/DOCKER.md similarity index 100% rename from DOCKER.md rename to docs/DOCKER.md diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..18c838b --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,177 @@ +# Installation Guide + +## Quick Install (Recommended) + +### 1. Install via Go + +```bash +# Install the latest version +go install github.com/armitageee/faceit-cli@latest + +# Initialize configuration +faceit-cli init + +# Edit the config file and add your API key +# ~/.config/faceit-cli/config.yml + +# Run the application +faceit-cli +``` + +### 2. Configure + +After running `faceit-cli init`, edit the configuration file: + +```bash +# Edit the config file +nano ~/.config/faceit-cli/config.yml +# or +code ~/.config/faceit-cli/config.yml +``` + +Set your FACEIT API key: + +```yaml +api_key: "your_actual_faceit_api_key_here" +``` + +## Alternative Installation Methods + +### Pre-built Binaries + +1. Download the latest release from [GitHub Releases](https://github.com/armitageee/faceit-cli/releases) +2. Extract the binary for your platform +3. Move to a directory in your PATH (e.g., `/usr/local/bin` on macOS/Linux) +4. Run `faceit-cli init` to create configuration + +### Docker + +```bash +# Clone the repository +git clone https://github.com/armitageee/faceit-cli.git +cd faceit-cli + +# Set up environment +cp .env.example .env +# Edit .env and add your FACEIT_API_KEY + +# Build and run with Docker +make docker-build +make docker-run +``` + +### Building from Source + +```bash +# Clone the repository +git clone https://github.com/armitageee/faceit-cli.git +cd faceit-cli + +# Build the application +make build + +# Initialize configuration +./faceit-cli init + +# Run the application +./faceit-cli +``` + +## Configuration + +The application uses a flexible configuration system with priority order: + +1. **Environment Variables** (highest priority) +2. **YAML Configuration** (`~/.config/faceit-cli/config.yml`) +3. **Default Values** (lowest priority) + +### 1. YAML Configuration + +Location: `~/.config/faceit-cli/config.yml` + +```yaml +# Required +api_key: "your_faceit_api_key_here" + +# Optional settings +default_player: "your_nickname" +log_level: "info" +matches_per_page: 10 +max_matches_to_load: 100 +cache_enabled: false +telemetry_enabled: false +# ... and more +``` + +### 2. Environment Variables Override + +Environment variables always override YAML configuration. This is useful for: +- Production deployments +- CI/CD pipelines +- Temporary overrides +- Security-sensitive values + +If no YAML config is found, the application falls back to environment variables: + +```bash +export FACEIT_API_KEY="your_api_key_here" +export FACEIT_DEFAULT_PLAYER="your_nickname" +export LOG_LEVEL="info" +# ... and more +``` + +## Platform-specific Notes + +### macOS + +- Config location: `~/.config/faceit-cli/config.yml` +- Install via Homebrew: `go install github.com/armitageee/faceit-cli@latest` + +### Linux + +- Config location: `~/.config/faceit-cli/config.yml` +- Make sure `~/.local/bin` is in your PATH for `go install` + +### Windows + +- Config location: `%USERPROFILE%\.config\faceit-cli\config.yml` +- Use PowerShell or Command Prompt for installation + +## Troubleshooting + +### "command not found: faceit-cli" + +Make sure the Go binary directory is in your PATH: + +```bash +# Add to ~/.bashrc or ~/.zshrc +export PATH=$PATH:$(go env GOPATH)/bin +``` + +### "API key not configured" + +1. Run `faceit-cli init` to create the config file +2. Edit `~/.config/faceit-cli/config.yml` +3. Set your `api_key` value + +### "config file not found" + +The application will create the config directory automatically when you run `faceit-cli init`. + +## Getting Your FACEIT API Key + +1. Go to [FACEIT Developer Portal](https://developers.faceit.com/) +2. Sign in with your FACEIT account +3. Create a new application +4. Copy the API key +5. Add it to your configuration file + +## Updating + +To update to the latest version: + +```bash +go install github.com/armitageee/faceit-cli@latest +``` + +Your configuration will be preserved. diff --git a/LOGGING.md b/docs/LOGGING.md similarity index 100% rename from LOGGING.md rename to docs/LOGGING.md diff --git a/docs/QUICKSTART_TRACING.md b/docs/QUICKSTART_TRACING.md new file mode 100644 index 0000000..2e1821b --- /dev/null +++ b/docs/QUICKSTART_TRACING.md @@ -0,0 +1,80 @@ +# Быстрый старт с трейсингом + +## Запуск с трейсингом + +### 1. Запуск инфраструктуры +```bash +# Запуск всех сервисов включая трейсинг +docker-compose up -d + +# Проверка статуса +docker-compose ps +``` + +### 2. Настройка переменных окружения +```bash +# Скопируйте пример конфигурации +cp .env.example .env + +# Отредактируйте .env файл - установите TELEMETRY_ENABLED=true +echo "TELEMETRY_ENABLED=true" >> .env +echo "OTLP_ENDPOINT=http://localhost:4318" >> .env +echo "FACEIT_API_KEY=your_api_key_here" >> .env +``` + +### 3. Запуск приложения +```bash +# Сборка +go build -o faceit-cli main.go + +# Запуск с трейсингом +./faceit-cli +``` + +## Просмотр трейсов + +### Zipkin UI +- URL: http://localhost:9411 +- Нажмите "Run Query" для просмотра всех трейсов +- Фильтруйте по сервису "faceit-cli" + +### Jaeger UI (опционально) +```bash +# Запуск с Jaeger +docker-compose --profile jaeger up -d + +# URL: http://localhost:16686 +``` + +## Отключение трейсинга + +```bash +# В .env файле +TELEMETRY_ENABLED=false + +# Или через переменную окружения +TELEMETRY_ENABLED=false ./faceit-cli +``` + +## Что трейсится + +- **app.run** - Основной трейс выполнения приложения +- **app.init_ui** - Инициализация UI +- **app.tui_execution** - Выполнение TUI программы +- **repository.get_player_by_nickname** - Поиск игрока по никнейму +- **repository.get_player_stats** - Получение статистики игрока +- **repository.get_player_recent_matches** - Получение последних матчей +- **repository.get_match_stats** - Получение статистики матча + +## Полезные команды + +```bash +# Просмотр логов OpenTelemetry Collector +docker-compose logs otel-collector + +# Просмотр логов Zipkin +docker-compose logs zipkin + +# Остановка всех сервисов +docker-compose down +``` diff --git a/TESTING.md b/docs/TESTING.md similarity index 100% rename from TESTING.md rename to docs/TESTING.md diff --git a/docs/TRACING.md b/docs/TRACING.md new file mode 100644 index 0000000..26a93a5 --- /dev/null +++ b/docs/TRACING.md @@ -0,0 +1,147 @@ +# OpenTelemetry Tracing + +Этот документ описывает настройку и использование OpenTelemetry для трейсинга в faceit-cli. + +## Обзор + +Приложение интегрировано с OpenTelemetry для сбора и отправки трейсов. Трейсы помогают отслеживать производительность и отлаживать проблемы в CLI утилите. + +## Компоненты + +### 1. OpenTelemetry Collector +- **Порт**: 4317 (gRPC), 4318 (HTTP) +- **Конфигурация**: `otel-collector-config.yaml` +- **Функция**: Собирает трейсы от приложения и отправляет их в Zipkin + +### 2. Zipkin +- **Порт**: 9411 +- **URL**: http://localhost:9411 +- **Функция**: Визуализация трейсов + +### 3. Jaeger (опционально) +- **Порт**: 16686 +- **URL**: http://localhost:16686 +- **Функция**: Альтернативная визуализация трейсов + +## Запуск с трейсингом + +### 1. Запуск инфраструктуры +```bash +# Запуск всех сервисов включая трейсинг +docker-compose up -d + +# Или только трейсинг сервисы +docker-compose up -d otel-collector zipkin +``` + +### 2. Настройка переменных окружения +```bash +# Скопируйте пример конфигурации +cp .env.example .env + +# Отредактируйте .env файл +# Убедитесь что TELEMETRY_ENABLED=true +``` + +### 3. Запуск приложения +```bash +# С трейсингом +TELEMETRY_ENABLED=true ./faceit-cli + +# Или используйте .env файл +./faceit-cli +``` + +## Конфигурация + +### Переменные окружения + +| Переменная | Описание | По умолчанию | +|------------|----------|--------------| +| `TELEMETRY_ENABLED` | Включить трейсинг | `false` | +| `OTLP_ENDPOINT` | OTLP HTTP endpoint (без /v1/traces) | `http://localhost:4318` | +| `ZIPKIN_ENDPOINT` | Zipkin endpoint | `http://localhost:9411/api/v2/spans` | +| `SERVICE_NAME` | Имя сервиса | `faceit-cli` | +| `SERVICE_VERSION` | Версия сервиса | `dev` | +| `ENVIRONMENT` | Окружение | `development` | +| `OTEL_LOG_LEVEL` | Уровень логов OpenTelemetry | `fatal` (подавляет OTLP логи) | + +### Трейсы + +Приложение создает следующие трейсы: + +1. **app.run** - Основной трейс выполнения приложения +2. **app.init_ui** - Инициализация UI +3. **app.tui_execution** - Выполнение TUI программы +4. **repository.get_player_by_nickname** - Поиск игрока по никнейму +5. **repository.get_player_stats** - Получение статистики игрока +6. **repository.get_player_recent_matches** - Получение последних матчей +7. **repository.get_match_stats** - Получение статистики матча + +## Просмотр трейсов + +### Zipkin +1. Откройте http://localhost:9411 +2. Нажмите "Run Query" для просмотра всех трейсов +3. Используйте фильтры для поиска конкретных трейсов + +### Jaeger (если используется) +1. Запустите с профилем jaeger: `docker-compose --profile jaeger up -d` +2. Откройте http://localhost:16686 +3. Выберите сервис "faceit-cli" и нажмите "Find Traces" + +## Отладка + +### Проверка статуса сервисов +```bash +# Проверка статуса контейнеров +docker-compose ps + +# Логи OpenTelemetry Collector +docker-compose logs otel-collector + +# Логи Zipkin +docker-compose logs zipkin +``` + +### Тестирование OTLP endpoint +```bash +# Проверка доступности OTLP HTTP endpoint +curl http://localhost:4318/v1/traces + +# Проверка Zipkin API +curl http://localhost:9411/api/v2/services +``` + +## Производительность + +Трейсинг добавляет минимальные накладные расходы: +- Время выполнения увеличивается на ~1-2ms на операцию +- Память: ~1-2MB дополнительно +- Сетевой трафик: ~1-5KB на трейс + +## Отключение трейсинга + +Для отключения трейсинга установите: +```bash +export TELEMETRY_ENABLED=false +``` + +Или в .env файле: +``` +TELEMETRY_ENABLED=false +``` + +## Подавление OTLP логов + +По умолчанию OTLP логи подавляются, чтобы не засорять stdout. Все трейсы отправляются только в Zipkin через OTLP коллектор. + +Если нужно включить OTLP логи для отладки: +```bash +export OTEL_LOG_LEVEL=debug +``` + +Или в .env файле: +``` +OTEL_LOG_LEVEL=debug +``` diff --git a/go.mod b/go.mod index f9bbcd6..b802421 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module faceit-cli +module github.com/armitageee/faceit-cli go 1.23.0 @@ -12,16 +12,26 @@ require ( github.com/mconnat/go-faceit v1.0.3 github.com/segmentio/kafka-go v0.4.49 github.com/sirupsen/logrus v1.9.3 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 + go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/klauspost/compress v1.15.9 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/klauspost/compress v1.16.6 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -29,11 +39,20 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/pierrec/lz4/v4 v4.1.15 // indirect + github.com/pierrec/lz4/v4 v4.1.17 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index 2dc01b1..b5ea352 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwc github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= @@ -19,10 +21,27 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/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/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= -github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk= +github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -39,21 +58,23 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= -github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= +github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk= github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= @@ -62,22 +83,54 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/app.go b/internal/app/app.go index e45f818..08b8f13 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,26 +5,30 @@ import ( "fmt" "time" - "faceit-cli/internal/cache" - "faceit-cli/internal/config" - "faceit-cli/internal/logger" - "faceit-cli/internal/repository" - "faceit-cli/internal/ui" + "github.com/armitageee/faceit-cli/internal/cache" + "github.com/armitageee/faceit-cli/internal/config" + "github.com/armitageee/faceit-cli/internal/logger" + "github.com/armitageee/faceit-cli/internal/repository" + "github.com/armitageee/faceit-cli/internal/telemetry" + "github.com/armitageee/faceit-cli/internal/ui" tea "github.com/charmbracelet/bubbletea" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" ) // App represents the main application type App struct { - config *config.Config - repo repository.FaceitRepository - logger *logger.Logger + config *config.Config + repo repository.FaceitRepository + logger *logger.Logger + telemetry *telemetry.Telemetry } // NewApp creates a new application instance -func NewApp(cfg *config.Config, appLogger *logger.Logger) *App { - // Initialize repository with optional caching - var repo repository.FaceitRepository = repository.NewFaceitRepository(cfg.FaceitAPIKey) +func NewApp(cfg *config.Config, appLogger *logger.Logger, telemetryInstance *telemetry.Telemetry) *App { + // Initialize repository with telemetry support + var repo repository.FaceitRepository = repository.NewFaceitRepository(cfg.FaceitAPIKey, telemetryInstance) if cfg.CacheEnabled { appLogger.Info("Cache enabled", map[string]interface{}{ @@ -34,18 +38,65 @@ func NewApp(cfg *config.Config, appLogger *logger.Logger) *App { } return &App{ - config: cfg, - repo: repo, - logger: appLogger, + config: cfg, + repo: repo, + logger: appLogger, + telemetry: telemetryInstance, } } // Run starts the application func (a *App) Run(ctx context.Context) error { + if a.telemetry != nil { + return a.telemetry.WithSpan(ctx, "app.run", func(ctx context.Context) error { + return a.runInternal(ctx) + }) + } + return a.runInternal(ctx) +} + +// runInternal contains the actual run logic +func (a *App) runInternal(ctx context.Context) error { a.logger.Info("Initializing UI model") - model := ui.InitialModel(a.repo, a.config, a.logger) + // Create a span for UI initialization if telemetry is enabled + if a.telemetry != nil { + ctx, span := a.telemetry.StartSpan(ctx, "app.init_ui") + span.SetAttributes( + attribute.Bool("cache_enabled", a.config.CacheEnabled), + attribute.Int("cache_ttl_minutes", a.config.CacheTTL), + attribute.Int("matches_per_page", a.config.MatchesPerPage), + ) + + model := ui.InitialModel(a.repo, a.config, a.logger) + span.End() + + a.logger.Info("Starting TUI program") + + // Create a span for TUI execution + _, tuiSpan := a.telemetry.StartSpan(ctx, "app.tui_execution") + defer tuiSpan.End() + + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) + + if _, err := p.Run(); err != nil { + tuiSpan.RecordError(err) + tuiSpan.SetStatus(codes.Error, err.Error()) + a.logger.Error("TUI program failed", map[string]interface{}{ + "error": err.Error(), + }) + return fmt.Errorf("failed to run application: %w", err) + } + + tuiSpan.SetStatus(codes.Ok, "TUI completed successfully") + a.logger.Info("TUI program completed successfully") + return nil + } + + // No telemetry - run without tracing + model := ui.InitialModel(a.repo, a.config, a.logger) a.logger.Info("Starting TUI program") + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f1d46d0..d74b5bd 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -4,8 +4,9 @@ import ( "context" "testing" - "faceit-cli/internal/config" - "faceit-cli/internal/logger" + "github.com/armitageee/faceit-cli/internal/config" + "github.com/armitageee/faceit-cli/internal/logger" + "github.com/armitageee/faceit-cli/internal/telemetry" ) // createTestLogger creates a test logger @@ -21,12 +22,34 @@ func createTestLogger() *logger.Logger { return logger } +// createTestTelemetry creates a test telemetry instance (disabled) +func createTestTelemetry() *telemetry.Telemetry { + // Create a proper telemetry instance but with tracing disabled + ctx := context.Background() + config := telemetry.Config{ + ServiceName: "test-service", + ServiceVersion: "test", + Environment: "test", + Enabled: false, // Disabled for tests + } + + telemetryInstance, err := telemetry.New(ctx, config) + if err != nil { + // If telemetry creation fails, return a nil instance + // The app should handle nil telemetry gracefully + return nil + } + + return telemetryInstance +} + func TestNewApp(t *testing.T) { tests := []struct { - name string - config *config.Config - logger *logger.Logger - wantErr bool + name string + config *config.Config + logger *logger.Logger + telemetry *telemetry.Telemetry + wantErr bool }{ { name: "valid config with cache disabled", @@ -34,8 +57,9 @@ func TestNewApp(t *testing.T) { FaceitAPIKey: "test-api-key", CacheEnabled: false, }, - logger: createTestLogger(), - wantErr: false, + logger: createTestLogger(), + telemetry: createTestTelemetry(), + wantErr: false, }, { name: "valid config with cache enabled", @@ -44,14 +68,16 @@ func TestNewApp(t *testing.T) { CacheEnabled: true, CacheTTL: 30, }, - logger: createTestLogger(), - wantErr: false, + logger: createTestLogger(), + telemetry: createTestTelemetry(), + wantErr: false, }, { - name: "nil config", - config: nil, - logger: createTestLogger(), - wantErr: true, + name: "nil config", + config: nil, + logger: createTestLogger(), + telemetry: createTestTelemetry(), + wantErr: true, }, } @@ -63,7 +89,7 @@ func TestNewApp(t *testing.T) { } }() - app := NewApp(tt.config, tt.logger) + app := NewApp(tt.config, tt.logger, tt.telemetry) if tt.wantErr { if app != nil { @@ -85,6 +111,10 @@ func TestNewApp(t *testing.T) { t.Errorf("NewApp() logger = %v, want %v", app.logger, tt.logger) } + if app.telemetry != tt.telemetry { + t.Errorf("NewApp() telemetry = %v, want %v", app.telemetry, tt.telemetry) + } + if app.repo == nil { t.Errorf("NewApp() repo is nil") } @@ -117,7 +147,7 @@ func TestApp_Run(t *testing.T) { } appLogger := createTestLogger() - app := NewApp(config, appLogger) + app := NewApp(config, appLogger, createTestTelemetry()) // Test with a cancelled context to avoid hanging ctx, cancel := context.WithCancel(context.Background()) @@ -142,7 +172,7 @@ func TestApp_Fields(t *testing.T) { } appLogger := createTestLogger() - app := NewApp(config, appLogger) + app := NewApp(config, appLogger, createTestTelemetry()) // Test that all fields are properly set if app.config == nil { @@ -157,6 +187,10 @@ func TestApp_Fields(t *testing.T) { t.Error("App.logger is nil") } + if app.telemetry == nil { + t.Error("App.telemetry is nil") + } + // Test config values if app.config.FaceitAPIKey != "test-api-key" { t.Errorf("App.config.FaceitAPIKey = %s, want test-api-key", app.config.FaceitAPIKey) @@ -179,10 +213,11 @@ func BenchmarkNewApp(b *testing.B) { CacheTTL: 30, } appLogger := createTestLogger() + telemetry := createTestTelemetry() b.ResetTimer() for i := 0; i < b.N; i++ { - _ = NewApp(config, appLogger) + _ = NewApp(config, appLogger, telemetry) } } @@ -192,9 +227,10 @@ func BenchmarkNewApp_NoCache(b *testing.B) { CacheEnabled: false, } appLogger := createTestLogger() + telemetry := createTestTelemetry() b.ResetTimer() for i := 0; i < b.N; i++ { - _ = NewApp(config, appLogger) + _ = NewApp(config, appLogger, telemetry) } } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 7d35f84..5d75d48 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "faceit-cli/internal/entity" + "github.com/armitageee/faceit-cli/internal/entity" ) // CacheEntry represents a cached item with expiration diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index fce61ae..433db8e 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "faceit-cli/internal/entity" + "github.com/armitageee/faceit-cli/internal/entity" ) func TestCacheBasicOperations(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index cefb2b1..c244382 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,10 +22,32 @@ type Config struct { CacheEnabled bool CacheTTL int // Cache TTL in minutes ComparisonMatches int // Number of matches to use for comparison + // Telemetry configuration + TelemetryEnabled bool + OTLPEndpoint string + ServiceName string + ServiceVersion string + Environment string } -// Load loads configuration from environment variables +// Load loads configuration with environment variables taking priority over YAML config func Load() (*Config, error) { + var yamlConfig *YAMLConfig + var err error + + // Try to load YAML config first + yamlConfig, err = LoadYAMLConfig() + if err != nil { + // If YAML config doesn't exist or fails, fall back to environment variables only + return loadFromEnv() + } + + // Convert YAML config to Config struct with environment variable overrides + return convertYAMLToConfig(yamlConfig) +} + +// loadFromEnv loads configuration from environment variables (fallback) +func loadFromEnv() (*Config, error) { apiKey := os.Getenv("FACEIT_API_KEY") if apiKey == "" { return nil, fmt.Errorf("FACEIT_API_KEY environment variable is required") @@ -84,6 +106,26 @@ func Load() (*Config, error) { } } + // Parse telemetry settings + telemetryEnabled := os.Getenv("TELEMETRY_ENABLED") == "true" + otlpEndpoint := os.Getenv("OTLP_ENDPOINT") + if otlpEndpoint == "" { + otlpEndpoint = "localhost:4317" + } + // Zipkin endpoint is handled by OTLP Collector + serviceName := os.Getenv("SERVICE_NAME") + if serviceName == "" { + serviceName = "faceit-cli" + } + serviceVersion := os.Getenv("SERVICE_VERSION") + if serviceVersion == "" { + serviceVersion = "dev" + } + environment := os.Getenv("ENVIRONMENT") + if environment == "" { + environment = "development" + } + return &Config{ FaceitAPIKey: apiKey, DefaultPlayer: defaultPlayer, @@ -98,5 +140,81 @@ func Load() (*Config, error) { CacheEnabled: cacheEnabled, CacheTTL: cacheTTL, ComparisonMatches: comparisonMatches, + TelemetryEnabled: telemetryEnabled, + OTLPEndpoint: otlpEndpoint, + ServiceName: serviceName, + ServiceVersion: serviceVersion, + Environment: environment, + }, nil +} + +// convertYAMLToConfig converts YAMLConfig to Config with environment variable overrides +func convertYAMLToConfig(yamlConfig *YAMLConfig) (*Config, error) { + // API Key: Environment variable takes priority + apiKey := os.Getenv("FACEIT_API_KEY") + if apiKey == "" { + apiKey = yamlConfig.APIKey + } + if apiKey == "" || apiKey == "your_faceit_api_key_here" { + return nil, fmt.Errorf("API key not configured. Please set FACEIT_API_KEY environment variable or 'api_key' in ~/.config/faceit-cli/config.yml") + } + + // Helper function to get value with env override + getStringValue := func(envKey, yamlValue, defaultValue string) string { + if envValue := os.Getenv(envKey); envValue != "" { + return envValue + } + if yamlValue != "" { + return yamlValue + } + return defaultValue + } + + getBoolValue := func(envKey string, yamlValue, defaultValue bool) bool { + if envValue := os.Getenv(envKey); envValue != "" { + return envValue == "true" + } + return yamlValue + } + + getIntValue := func(envKey string, yamlValue, defaultValue int) int { + if envValue := os.Getenv(envKey); envValue != "" { + if parsed, err := strconv.Atoi(envValue); err == nil { + return parsed + } + } + if yamlValue != 0 { + return yamlValue + } + return defaultValue + } + + // Parse kafka brokers with env override + kafkaBrokers := []string{"localhost:9092"} + if envBrokers := os.Getenv("KAFKA_BROKERS"); envBrokers != "" { + kafkaBrokers = strings.Split(envBrokers, ",") + } else if yamlConfig.KafkaBrokers != "" { + kafkaBrokers = strings.Split(yamlConfig.KafkaBrokers, ",") + } + + return &Config{ + FaceitAPIKey: apiKey, + DefaultPlayer: getStringValue("FACEIT_DEFAULT_PLAYER", yamlConfig.DefaultPlayer, ""), + LogLevel: getStringValue("LOG_LEVEL", yamlConfig.LogLevel, "info"), + KafkaEnabled: getBoolValue("KAFKA_ENABLED", yamlConfig.KafkaEnabled, false), + KafkaBrokers: kafkaBrokers, + KafkaTopic: getStringValue("KAFKA_TOPIC", yamlConfig.KafkaTopic, "faceit-cli-logs"), + ProductionMode: getBoolValue("PRODUCTION_MODE", yamlConfig.ProductionMode, false), + LogToStdout: getBoolValue("LOG_TO_STDOUT", yamlConfig.LogToStdout, true), + MatchesPerPage: getIntValue("MATCHES_PER_PAGE", yamlConfig.MatchesPerPage, 10), + MaxMatchesToLoad: getIntValue("MAX_MATCHES_TO_LOAD", yamlConfig.MaxMatchesToLoad, 100), + CacheEnabled: getBoolValue("CACHE_ENABLED", yamlConfig.CacheEnabled, false), + CacheTTL: getIntValue("CACHE_TTL", yamlConfig.CacheTTL, 30), + ComparisonMatches: getIntValue("COMPARISON_MATCHES", yamlConfig.ComparisonMatches, 20), + TelemetryEnabled: getBoolValue("TELEMETRY_ENABLED", yamlConfig.TelemetryEnabled, false), + OTLPEndpoint: getStringValue("OTLP_ENDPOINT", yamlConfig.OTLPEndpoint, "localhost:4317"), + ServiceName: getStringValue("SERVICE_NAME", yamlConfig.ServiceName, "faceit-cli"), + ServiceVersion: getStringValue("SERVICE_VERSION", yamlConfig.ServiceVersion, "1.0.0"), + Environment: getStringValue("ENVIRONMENT", yamlConfig.Environment, "development"), }, nil } diff --git a/internal/config/config_priority_test.go b/internal/config/config_priority_test.go new file mode 100644 index 0000000..23da2ff --- /dev/null +++ b/internal/config/config_priority_test.go @@ -0,0 +1,80 @@ +package config + +import ( + "os" + "testing" +) + +func TestConfigPriority(t *testing.T) { + // Create a temporary YAML config + yamlConfig := &YAMLConfig{ + APIKey: "yaml_api_key", + LogLevel: "debug", + CacheEnabled: true, + MatchesPerPage: 5, + } + + // Test 1: No environment variables - should use YAML values + os.Clearenv() + config, err := convertYAMLToConfig(yamlConfig) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if config.FaceitAPIKey != "yaml_api_key" { + t.Errorf("Expected API key 'yaml_api_key', got '%s'", config.FaceitAPIKey) + } + if config.LogLevel != "debug" { + t.Errorf("Expected log level 'debug', got '%s'", config.LogLevel) + } + if !config.CacheEnabled { + t.Errorf("Expected cache enabled true, got false") + } + if config.MatchesPerPage != 5 { + t.Errorf("Expected matches per page 5, got %d", config.MatchesPerPage) + } + + // Test 2: Environment variables should override YAML + os.Setenv("FACEIT_API_KEY", "env_api_key") + os.Setenv("LOG_LEVEL", "warn") + os.Setenv("CACHE_ENABLED", "false") + os.Setenv("MATCHES_PER_PAGE", "15") + + config, err = convertYAMLToConfig(yamlConfig) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if config.FaceitAPIKey != "env_api_key" { + t.Errorf("Expected API key 'env_api_key', got '%s'", config.FaceitAPIKey) + } + if config.LogLevel != "warn" { + t.Errorf("Expected log level 'warn', got '%s'", config.LogLevel) + } + if config.CacheEnabled { + t.Errorf("Expected cache enabled false, got true") + } + if config.MatchesPerPage != 15 { + t.Errorf("Expected matches per page 15, got %d", config.MatchesPerPage) + } + + // Test 3: Empty environment variables should fall back to YAML + os.Setenv("FACEIT_API_KEY", "") + os.Setenv("LOG_LEVEL", "") + os.Setenv("CACHE_ENABLED", "") + + config, err = convertYAMLToConfig(yamlConfig) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if config.FaceitAPIKey != "yaml_api_key" { + t.Errorf("Expected API key 'yaml_api_key', got '%s'", config.FaceitAPIKey) + } + if config.LogLevel != "debug" { + t.Errorf("Expected log level 'debug', got '%s'", config.LogLevel) + } + if !config.CacheEnabled { + t.Errorf("Expected cache enabled true, got false") + } +} diff --git a/internal/config/yaml_config.go b/internal/config/yaml_config.go new file mode 100644 index 0000000..38e368b --- /dev/null +++ b/internal/config/yaml_config.go @@ -0,0 +1,124 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// YAMLConfig represents the YAML configuration structure +type YAMLConfig struct { + APIKey string `yaml:"api_key"` + DefaultPlayer string `yaml:"default_player"` + LogLevel string `yaml:"log_level"` + KafkaEnabled bool `yaml:"kafka_enabled"` + KafkaBrokers string `yaml:"kafka_brokers"` + KafkaTopic string `yaml:"kafka_topic"` + ProductionMode bool `yaml:"production_mode"` + LogToStdout bool `yaml:"log_to_stdout"` + MatchesPerPage int `yaml:"matches_per_page"` + MaxMatchesToLoad int `yaml:"max_matches_to_load"` + CacheEnabled bool `yaml:"cache_enabled"` + CacheTTL int `yaml:"cache_ttl"` + ComparisonMatches int `yaml:"comparison_matches"` + // Telemetry configuration + TelemetryEnabled bool `yaml:"telemetry_enabled"` + OTLPEndpoint string `yaml:"otlp_endpoint"` + ServiceName string `yaml:"service_name"` + ServiceVersion string `yaml:"service_version"` + Environment string `yaml:"environment"` + OTELLogLevel string `yaml:"otel_log_level"` +} + +// GetConfigPath returns the appropriate config file path for the current platform +func GetConfigPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + // Use ~/.config/faceit-cli/config.yml + configDir := filepath.Join(homeDir, ".config", "faceit-cli") + configFile := filepath.Join(configDir, "config.yml") + + return configFile, nil +} + +// LoadYAMLConfig loads configuration from YAML file +func LoadYAMLConfig() (*YAMLConfig, error) { + configPath, err := GetConfigPath() + if err != nil { + return nil, err + } + + // Check if config file exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return nil, fmt.Errorf("config file not found at %s", configPath) + } + + // Read config file + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // Parse YAML + var config YAMLConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse YAML config: %w", err) + } + + return &config, nil +} + +// CreateDefaultConfig creates a default config file with example values +func CreateDefaultConfig() error { + configPath, err := GetConfigPath() + if err != nil { + return err + } + + // Create config directory if it doesn't exist + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Create default config with all fields + defaultConfig := YAMLConfig{ + APIKey: "your_faceit_api_key_here", + DefaultPlayer: "", + LogLevel: "info", + KafkaEnabled: false, + KafkaBrokers: "localhost:9092", + KafkaTopic: "faceit-cli-logs", + ProductionMode: false, + LogToStdout: false, + MatchesPerPage: 10, + MaxMatchesToLoad: 100, + CacheEnabled: true, + CacheTTL: 30, + ComparisonMatches: 20, + TelemetryEnabled: false, + OTLPEndpoint: "localhost:4317", + ServiceName: "faceit-cli", + ServiceVersion: "1.0.0", + Environment: "development", + OTELLogLevel: "fatal", + } + + // Marshal to YAML + data, err := yaml.Marshal(defaultConfig) + if err != nil { + return fmt.Errorf("failed to marshal default config: %w", err) + } + + // Write to file + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} diff --git a/internal/repository/faceit.go b/internal/repository/faceit.go index 6fa8e6f..f35b671 100644 --- a/internal/repository/faceit.go +++ b/internal/repository/faceit.go @@ -9,11 +9,15 @@ import ( "sort" - "faceit-cli/internal/entity" - "faceit-cli/internal/logger" + "github.com/armitageee/faceit-cli/internal/entity" + "github.com/armitageee/faceit-cli/internal/logger" + "github.com/armitageee/faceit-cli/internal/telemetry" "github.com/antihax/optional" faceit "github.com/mconnat/go-faceit" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) // FaceitRepository defines the interface for FACEIT API operations @@ -28,16 +32,17 @@ type FaceitRepository interface { // delegates calls to the generated FACEIT API client. It stores the API // key and applies it to each request via the context. type faceitRepository struct { - client *faceit.APIClient - apiKey string - logger *logger.Logger + client *faceit.APIClient + apiKey string + logger *logger.Logger + telemetry *telemetry.Telemetry } // NewFaceitRepository constructs a repository backed by the FACEIT API. // It takes an API key which will be sent with each request. The // underlying API client is created with default configuration – // including the base URL "https://open.faceit.com/data/v4". -func NewFaceitRepository(apiKey string) FaceitRepository { +func NewFaceitRepository(apiKey string, telemetryInstance *telemetry.Telemetry) FaceitRepository { cfg := faceit.NewConfiguration() client := faceit.NewAPIClient(cfg) @@ -52,9 +57,10 @@ func NewFaceitRepository(apiKey string) FaceitRepository { appLogger, _ := logger.New(loggerConfig) return &faceitRepository{ - client: client, - apiKey: apiKey, - logger: appLogger, + client: client, + apiKey: apiKey, + logger: appLogger, + telemetry: telemetryInstance, } } @@ -70,6 +76,17 @@ func (r *faceitRepository) contextWithAPIKey(ctx context.Context) context.Contex // API calls: first a search to find the player ID and then a fetch of // the player's profile. func (r *faceitRepository) GetPlayerByNickname(ctx context.Context, nickname string) (*entity.PlayerProfile, error) { + // Start tracing span if telemetry is enabled + var span trace.Span + if r.telemetry != nil { + ctx, span = r.telemetry.StartSpan(ctx, "repository.get_player_by_nickname") + defer span.End() + + r.setSpanAttributes(span, + attribute.String("player.nickname", nickname), + ) + } + r.logger.Debug("Starting GetPlayerByNickname", map[string]interface{}{ "nickname": nickname, }) @@ -151,6 +168,12 @@ func (r *faceitRepository) GetPlayerByNickname(ctx context.Context, nickname str } } + // Add telemetry attributes if enabled + r.setSpanSuccessWithAttributes(span, "Player found successfully", + attribute.String("player.id", profile.ID), + attribute.String("player.country", profile.Country), + ) + return profile, nil } @@ -160,6 +183,18 @@ func (r *faceitRepository) GetPlayerByNickname(ctx context.Context, nickname str // lifetime statistics. This implementation uses the latter via // GetPlayerStats_1. func (r *faceitRepository) GetPlayerStats(ctx context.Context, playerID, gameID string) (*entity.PlayerStats, error) { + // Start tracing span if telemetry is enabled + var span trace.Span + if r.telemetry != nil { + ctx, span = r.telemetry.StartSpan(ctx, "repository.get_player_stats") + defer span.End() + + r.setSpanAttributes(span, + attribute.String("player.id", playerID), + attribute.String("game.id", gameID), + ) + } + if playerID == "" { return nil, fmt.Errorf("playerID must not be empty") } @@ -170,6 +205,7 @@ func (r *faceitRepository) GetPlayerStats(ctx context.Context, playerID, gameID ctx = r.contextWithAPIKey(ctx) stats, _, err := r.client.PlayersApi.GetPlayerStats_1(ctx, playerID, gameID) if err != nil { + r.setSpanError(span, err) return nil, fmt.Errorf("get player stats: %w", err) } @@ -180,6 +216,12 @@ func (r *faceitRepository) GetPlayerStats(ctx context.Context, playerID, gameID Segments: stats.Segments, } + // Add telemetry attributes if enabled + r.setSpanSuccessWithAttributes(span, "Player stats retrieved successfully", + attribute.String("stats.game_id", result.GameID), + attribute.String("stats.player_id", result.PlayerID), + ) + return result, nil } @@ -190,6 +232,19 @@ func (r *faceitRepository) GetPlayerStats(ctx context.Context, playerID, gameID // matches is used. Any errors encountered during history or stats // retrieval are returned immediately. func (r *faceitRepository) GetPlayerRecentMatches(ctx context.Context, playerID string, gameID string, limit int) ([]entity.PlayerMatchSummary, error) { + // Start tracing span if telemetry is enabled + var span trace.Span + if r.telemetry != nil { + ctx, span = r.telemetry.StartSpan(ctx, "repository.get_player_recent_matches") + defer span.End() + + r.setSpanAttributes(span, + attribute.String("player.id", playerID), + attribute.String("game.id", gameID), + attribute.Int("matches.limit", limit), + ) + } + if playerID == "" { return nil, fmt.Errorf("playerID must not be empty") } @@ -243,6 +298,11 @@ func (r *faceitRepository) GetPlayerRecentMatches(ctx context.Context, playerID offset += len(history.Items) } + // Add telemetry attributes if enabled + r.setSpanSuccessWithAttributes(span, "Recent matches retrieved successfully", + attribute.Int("matches.count", len(allMatches)), + ) + // Debug logging (can be removed in production) // fmt.Printf("DEBUG: Requested limit: %d, Got matches: %d\n", limit, len(allMatches)) return allMatches, nil @@ -478,6 +538,17 @@ func (r *faceitRepository) processMatches(items []faceit.MatchHistory, playerID // GetMatchStats retrieves detailed match statistics by match ID func (r *faceitRepository) GetMatchStats(ctx context.Context, matchID string) (*entity.MatchStats, error) { + // Start tracing span if telemetry is enabled + var span trace.Span + if r.telemetry != nil { + ctx, span = r.telemetry.StartSpan(ctx, "repository.get_match_stats") + defer span.End() + + r.setSpanAttributes(span, + attribute.String("match.id", matchID), + ) + } + r.logger.Debug("Starting GetMatchStats", map[string]interface{}{ "match_id": matchID, }) @@ -751,6 +822,39 @@ func (r *faceitRepository) GetMatchStats(ctx context.Context, matchID string) (* } } + // Add telemetry attributes if enabled + r.setSpanSuccessWithAttributes(span, "Match stats retrieved successfully", + attribute.String("match.map", matchStats.Map), + attribute.String("match.score", matchStats.Score), + attribute.String("match.result", matchStats.Result), + ) + return matchStats, nil } +// Helper methods for telemetry operations + +// setSpanAttributes sets attributes on a span if telemetry is enabled +func (r *faceitRepository) setSpanAttributes(span trace.Span, attrs ...attribute.KeyValue) { + if r.telemetry != nil && span != nil { + span.SetAttributes(attrs...) + } +} + + +// setSpanError sets error status on a span if telemetry is enabled +func (r *faceitRepository) setSpanError(span trace.Span, err error) { + if r.telemetry != nil && span != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } +} + +// setSpanSuccessWithAttributes sets attributes and success status on a span if telemetry is enabled +func (r *faceitRepository) setSpanSuccessWithAttributes(span trace.Span, message string, attrs ...attribute.KeyValue) { + if r.telemetry != nil && span != nil { + span.SetAttributes(attrs...) + span.SetStatus(codes.Ok, message) + } +} + diff --git a/internal/repository/faceit_test.go b/internal/repository/faceit_test.go index 450b9cd..d8af9b9 100644 --- a/internal/repository/faceit_test.go +++ b/internal/repository/faceit_test.go @@ -5,11 +5,19 @@ import ( "os" "testing" "time" + + "github.com/armitageee/faceit-cli/internal/telemetry" ) +// createTestTelemetry creates a disabled telemetry instance for testing +func createTestTelemetry() *telemetry.Telemetry { + return telemetry.NewDisabled() +} + func TestNewFaceitRepository(t *testing.T) { apiKey := "test-api-key" - repo := NewFaceitRepository(apiKey) + telemetry := createTestTelemetry() + repo := NewFaceitRepository(apiKey, telemetry) if repo == nil { t.Fatal("Expected repository to be created, got nil") @@ -33,7 +41,8 @@ func TestGetPlayerByNickname(t *testing.T) { t.Skip("Skipping integration test in short mode") } - repo := NewFaceitRepository(apiKey) + telemetry := createTestTelemetry() + repo := NewFaceitRepository(apiKey, telemetry) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -72,7 +81,8 @@ func TestGetPlayerByNickname_InvalidPlayer(t *testing.T) { t.Skip("FACEIT_API_KEY not set, skipping integration test") } - repo := NewFaceitRepository(apiKey) + telemetry := createTestTelemetry() + repo := NewFaceitRepository(apiKey, telemetry) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -95,7 +105,8 @@ func TestGetPlayerStats(t *testing.T) { t.Skip("FACEIT_API_KEY not set, skipping integration test") } - repo := NewFaceitRepository(apiKey) + telemetry := createTestTelemetry() + repo := NewFaceitRepository(apiKey, telemetry) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -127,7 +138,8 @@ func TestGetPlayerRecentMatches(t *testing.T) { t.Skip("FACEIT_API_KEY not set, skipping integration test") } - repo := NewFaceitRepository(apiKey) + telemetry := createTestTelemetry() + repo := NewFaceitRepository(apiKey, telemetry) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -169,7 +181,8 @@ func TestGetPlayerRecentMatches_Pagination(t *testing.T) { t.Skip("FACEIT_API_KEY not set, skipping integration test") } - repo := NewFaceitRepository(apiKey) + telemetry := createTestTelemetry() + repo := NewFaceitRepository(apiKey, telemetry) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() @@ -206,7 +219,8 @@ func TestGetPlayerRecentMatches_InvalidPlayer(t *testing.T) { t.Skip("FACEIT_API_KEY not set, skipping integration test") } - repo := NewFaceitRepository(apiKey) + telemetry := createTestTelemetry() + repo := NewFaceitRepository(apiKey, telemetry) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -228,7 +242,8 @@ func TestGetPlayerRecentMatches_InvalidGame(t *testing.T) { t.Skip("FACEIT_API_KEY not set, skipping integration test") } - repo := NewFaceitRepository(apiKey) + telemetry := createTestTelemetry() + repo := NewFaceitRepository(apiKey, telemetry) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -256,7 +271,8 @@ func TestGetPlayerRecentMatches_EdgeCases(t *testing.T) { t.Skip("FACEIT_API_KEY not set, skipping integration test") } - repo := NewFaceitRepository(apiKey) + telemetry := createTestTelemetry() + repo := NewFaceitRepository(apiKey, telemetry) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -299,7 +315,8 @@ func BenchmarkGetPlayerByNickname(b *testing.B) { b.Skip("Skipping benchmark in short mode") } - repo := NewFaceitRepository(apiKey) + telemetry := createTestTelemetry() + repo := NewFaceitRepository(apiKey, telemetry) ctx := context.Background() b.ResetTimer() @@ -322,7 +339,8 @@ func BenchmarkGetPlayerRecentMatches(b *testing.B) { b.Skip("Skipping benchmark in short mode") } - repo := NewFaceitRepository(apiKey) + telemetry := createTestTelemetry() + repo := NewFaceitRepository(apiKey, telemetry) ctx := context.Background() // Get player ID once diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..6a46315 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,147 @@ +package telemetry + +import ( + "context" + "fmt" + "strings" + "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.24.0" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// Config holds telemetry configuration +type Config struct { + ServiceName string + ServiceVersion string + Environment string + OTLPEndpoint string + Enabled bool +} + +// Telemetry manages OpenTelemetry tracing +type Telemetry struct { + tracerProvider *sdktrace.TracerProvider + tracer trace.Tracer + shutdown func(context.Context) error +} + +// NewDisabled creates a disabled telemetry instance for testing +func NewDisabled() *Telemetry { + return &Telemetry{ + tracer: noop.NewTracerProvider().Tracer("faceit-cli-disabled"), + } +} + +// New creates a new telemetry instance +func New(ctx context.Context, cfg Config) (*Telemetry, error) { + if !cfg.Enabled { + // Return a no-op telemetry instance + return &Telemetry{ + tracer: noop.NewTracerProvider().Tracer("faceit-cli"), + }, nil + } + + // OTLP logs are suppressed by setting OTEL_LOG_LEVEL in main.go + + // Create resource + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(cfg.ServiceName), + semconv.ServiceVersion(cfg.ServiceVersion), + semconv.DeploymentEnvironment(cfg.Environment), + ), + ) + if err != nil { + return nil, fmt.Errorf("failed to create resource: %w", err) + } + + // Create exporters + var exporters []sdktrace.SpanExporter + + // OTLP gRPC exporter + if cfg.OTLPEndpoint != "" { + // For gRPC, we need to remove http:// prefix and use just host:port + endpoint := strings.TrimPrefix(cfg.OTLPEndpoint, "http://") + endpoint = strings.TrimPrefix(endpoint, "https://") + + // Using OTLP gRPC endpoint + + otlpExporter, err := otlptracegrpc.New(ctx, + otlptracegrpc.WithEndpoint(endpoint), + otlptracegrpc.WithInsecure(), // For development + ) + if err != nil { + return nil, fmt.Errorf("failed to create OTLP exporter: %w", err) + } + exporters = append(exporters, otlpExporter) + } + + // Note: Zipkin export is handled by OTLP Collector + // Direct Zipkin export is removed to use proper OTLP → Collector → Zipkin flow + + if len(exporters) == 0 { + return nil, fmt.Errorf("no exporters configured") + } + + // Create tracer provider with first exporter + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporters[0]), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.AlwaysSample()), + ) + + // Add additional exporters if any + for i := 1; i < len(exporters); i++ { + tp.RegisterSpanProcessor(sdktrace.NewBatchSpanProcessor(exporters[i])) + } + + // Set global tracer provider + otel.SetTracerProvider(tp) + + // Set global propagator + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return &Telemetry{ + tracerProvider: tp, + tracer: tp.Tracer("faceit-cli"), + shutdown: tp.Shutdown, + }, nil +} + +// Tracer returns the tracer instance +func (t *Telemetry) Tracer() trace.Tracer { + return t.tracer +} + +// Shutdown gracefully shuts down the telemetry +func (t *Telemetry) Shutdown(ctx context.Context) error { + if t.shutdown != nil { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return t.shutdown(ctx) + } + return nil +} + +// StartSpan creates a new span with the given name and options +func (t *Telemetry) StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { + return t.tracer.Start(ctx, name, opts...) +} + +// WithSpan executes a function within a span +func (t *Telemetry) WithSpan(ctx context.Context, name string, fn func(context.Context) error, opts ...trace.SpanStartOption) error { + ctx, span := t.StartSpan(ctx, name, opts...) + defer span.End() + + return fn(ctx) +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 0000000..2a43fbf --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,78 @@ +package telemetry + +import ( + "context" + "testing" +) + +func TestNew_Disabled(t *testing.T) { + config := Config{ + ServiceName: "test-service", + ServiceVersion: "1.0.0", + Environment: "test", + Enabled: false, + } + + telemetry, err := New(context.Background(), config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if telemetry == nil { + t.Fatal("Expected telemetry instance, got nil") + } + + // Test that tracer works (should be no-op) + _, span := telemetry.StartSpan(context.Background(), "test.span") + if span == nil { + t.Error("Expected span, got nil") + } + span.End() + + // Test WithSpan + err = telemetry.WithSpan(context.Background(), "test.withspan", func(ctx context.Context) error { + return nil + }) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } +} + +func TestNew_Enabled_NoExporters(t *testing.T) { + config := Config{ + ServiceName: "test-service", + ServiceVersion: "1.0.0", + Environment: "test", + Enabled: true, + // No exporters configured + } + + telemetry, err := New(context.Background(), config) + if err == nil { + t.Error("Expected error for no exporters, got nil") + } + + if telemetry != nil { + t.Error("Expected nil telemetry for error case") + } +} + +func TestTelemetry_Shutdown(t *testing.T) { + config := Config{ + ServiceName: "test-service", + ServiceVersion: "1.0.0", + Environment: "test", + Enabled: false, + } + + telemetry, err := New(context.Background(), config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Shutdown should not error + err = telemetry.Shutdown(context.Background()) + if err != nil { + t.Errorf("Expected no error on shutdown, got %v", err) + } +} diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go index 3610759..fbc4122 100644 --- a/internal/ui/helpers.go +++ b/internal/ui/helpers.go @@ -1,7 +1,7 @@ package ui import ( - "faceit-cli/internal/entity" + "github.com/armitageee/faceit-cli/internal/entity" "fmt" "strconv" "strings" diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go index 3c6e204..86abcf3 100644 --- a/internal/ui/helpers_test.go +++ b/internal/ui/helpers_test.go @@ -1,7 +1,7 @@ package ui import ( - "faceit-cli/internal/entity" + "github.com/armitageee/faceit-cli/internal/entity" "testing" ) diff --git a/internal/ui/models.go b/internal/ui/models.go index e0b801f..3774150 100644 --- a/internal/ui/models.go +++ b/internal/ui/models.go @@ -1,10 +1,10 @@ package ui import ( - "faceit-cli/internal/config" - "faceit-cli/internal/entity" - "faceit-cli/internal/logger" - "faceit-cli/internal/repository" + "github.com/armitageee/faceit-cli/internal/config" + "github.com/armitageee/faceit-cli/internal/entity" + "github.com/armitageee/faceit-cli/internal/logger" + "github.com/armitageee/faceit-cli/internal/repository" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" diff --git a/internal/ui/progress_test.go b/internal/ui/progress_test.go index 284058f..f16239c 100644 --- a/internal/ui/progress_test.go +++ b/internal/ui/progress_test.go @@ -3,8 +3,8 @@ package ui import ( "testing" - "faceit-cli/internal/config" - "faceit-cli/internal/logger" + "github.com/armitageee/faceit-cli/internal/config" + "github.com/armitageee/faceit-cli/internal/logger" ) func TestProgressBar(t *testing.T) { diff --git a/internal/ui/types.go b/internal/ui/types.go index c9f1608..57783dc 100644 --- a/internal/ui/types.go +++ b/internal/ui/types.go @@ -1,10 +1,10 @@ package ui import ( - "faceit-cli/internal/config" - "faceit-cli/internal/entity" - "faceit-cli/internal/logger" - "faceit-cli/internal/repository" + "github.com/armitageee/faceit-cli/internal/config" + "github.com/armitageee/faceit-cli/internal/entity" + "github.com/armitageee/faceit-cli/internal/logger" + "github.com/armitageee/faceit-cli/internal/repository" "github.com/charmbracelet/lipgloss" ) diff --git a/internal/ui/updates.go b/internal/ui/updates.go index 212edab..dfee4fc 100644 --- a/internal/ui/updates.go +++ b/internal/ui/updates.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "faceit-cli/internal/entity" + "github.com/armitageee/faceit-cli/internal/entity" tea "github.com/charmbracelet/bubbletea" ) diff --git a/main.go b/main.go index 1efb4c0..4ecc878 100644 --- a/main.go +++ b/main.go @@ -6,9 +6,10 @@ import ( "log" "os" - "faceit-cli/internal/app" - "faceit-cli/internal/config" - "faceit-cli/internal/logger" + "github.com/armitageee/faceit-cli/internal/app" + "github.com/armitageee/faceit-cli/internal/config" + "github.com/armitageee/faceit-cli/internal/logger" + "github.com/armitageee/faceit-cli/internal/telemetry" "github.com/joho/godotenv" ) @@ -17,12 +18,28 @@ import ( var version = "dev" func main() { + // Suppress OpenTelemetry logs immediately to avoid stdout pollution + // This must be done before any OpenTelemetry initialization + os.Setenv("OTEL_LOG_LEVEL", "fatal") + // Check for version flag if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version") { fmt.Printf("faceit-cli version %s\n", version) os.Exit(0) } + // Check for init command + if len(os.Args) > 1 && os.Args[1] == "init" { + if err := config.CreateDefaultConfig(); err != nil { + fmt.Printf("Error creating config file: %v\n", err) + os.Exit(1) + } + configPath, _ := config.GetConfigPath() + fmt.Printf("✅ Created default config file at: %s\n", configPath) + fmt.Println("📝 Please edit the config file and set your FACEIT API key") + os.Exit(0) + } + // Load environment variables from .env file if it exists _ = godotenv.Load() // .env file is optional, so we ignore errors @@ -56,7 +73,32 @@ func main() { ctx := context.Background() - application := app.NewApp(cfg, appLogger) + // Initialize telemetry + telemetryConfig := telemetry.Config{ + ServiceName: cfg.ServiceName, + ServiceVersion: version, + Environment: cfg.Environment, + OTLPEndpoint: cfg.OTLPEndpoint, + Enabled: cfg.TelemetryEnabled, + } + + telemetryInstance, err := telemetry.New(ctx, telemetryConfig) + if err != nil { + appLogger.Error("Failed to initialize telemetry", map[string]interface{}{ + "error": err.Error(), + }) + // Continue without telemetry + telemetryInstance = &telemetry.Telemetry{} + } + defer func() { + if err := telemetryInstance.Shutdown(ctx); err != nil { + appLogger.Error("Failed to shutdown telemetry", map[string]interface{}{ + "error": err.Error(), + }) + } + }() + + application := app.NewApp(cfg, appLogger, telemetryInstance) if err := application.Run(ctx); err != nil { appLogger.Error("Application failed", map[string]interface{}{ diff --git a/otel-collector-config.yaml b/otel-collector-config.yaml new file mode 100644 index 0000000..493dd6d --- /dev/null +++ b/otel-collector-config.yaml @@ -0,0 +1,50 @@ +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 + send_batch_max_size: 2048 + + memory_limiter: + limit_mib: 512 + spike_limit_mib: 128 + check_interval: 5s + + resource: + attributes: + - key: service.name + value: faceit-cli + action: upsert + - key: service.version + value: 1.0.0 + action: upsert + - key: deployment.environment + value: development + action: upsert + +exporters: + zipkin: + endpoint: "http://zipkin:9411/api/v2/spans" + format: json + + # Optional: Jaeger exporter (uncomment if using Jaeger) + # jaeger: + # endpoint: jaeger:14250 + # tls: + # insecure: true + +service: + pipelines: + traces: + receivers: [otlp] + processors: [memory_limiter, batch, resource] + exporters: [zipkin] + + extensions: []