Skip to main content

README.md

# ECS Elixir Core

[![Hex.pm](https://img.shields.io/hexpm/v/ecs_elixir_core.svg)](https://hex.pm/packages/ecs_elixir_core)
[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/ecs_elixir_core)
[![Licencia](https://img.shields.io/hexpm/l/ecs_elixir_core.svg)](https://github.com/bancolombia/ecs-elixir-core/blob/main/LICENSE)
[![CI](https://github.com/bancolombia/ecs-elixir-core/actions/workflows/main.yaml/badge.svg)](https://github.com/bancolombia/ecs-elixir-core/actions/workflows/main.yaml)

Librería Elixir para generación de logs estructurados bajo el estándar **Elastic Common Schema (ECS)**. Diseñada como middleware de logging para microservicios Elixir/Plug/Phoenix.

---

## Instalación

Agrega `ecs_elixir_core` a las dependencias en `mix.exs`:

```elixir
def deps do
  [
    {:ecs_elixir_core, "~> 1.0"}
  ]
end
```

Luego ejecuta:

```bash
mix deps.get
```

---

## Descripción

`ecs_elixir_core` estandariza cómo los microservicios Elixir registran errores y eventos. Cada log se serializa como JSON y se emite a través del sistema nativo de Elixir (`Logger`), listo para ser indexado por Elasticsearch/Kibana con el esquema ECS canónico de Bancolombia..

**Qué hace la librería:**

- Registra errores de negocio con contexto HTTP completo (método, URI, headers, body, código de respuesta).
- Registra respuestas exitosas (2xx) con cuerpo de respuesta.
- Valida y normaliza los datos de entrada antes de construir el log.
- Extrae automáticamente `consumer`, `message-id` y contexto HTTP del `conn` — el microservicio no los pasa manualmente.
- Normaliza el campo `message-id` desde cualquier variante del nombre (`messageId`, `messageid`, `message_id`, etc.).
- Aplica features configurables por variable de entorno: sampling, masking.
- Desacopla la tecnología de escritura del dominio mediante un puerto (`LogWriterPort`).
- Produce JSON idéntico al schema canónico Java (`LogRecord`) de la librería `ecs-logs`.

---

## Flujo de uso

```
[Microservicio]
    │
    ├── error  →  HandlerEcsRest.log(error_map, conn, message_id)
    └── éxito  →  HandlerEcsRest.log_success(result, conn, message_id)
                          │
                    Normaliza message_id (cualquier variante → "message-id")
                    Extrae consumer de conn.req_headers["consumer"]
                          │
                    EcsResponse.build / SuccessLog.build_structure
                    (construye EcsPayload con headers/body como maps)
                          │
                    EcsCommand{ payload: EcsPayload, context: Context }
                          │
                    EcsAppRestUseCase → EcsCoreUseCase
                    ├── CoreException.new(payload)  ← valida campos
                    ├── LogRecord.build_log_record  ← arma struct ECS
                    ├── run_features([:sampling, ...])
                    └── tech_to_print.write(log_record)
                                │
                         CmdLineLoggerEcs → Logger.error/info/...(JSON)
```

---

## Inicio rápido

### 1. Configurar la aplicación

En `config/config.exs`:

```elixir
config :ecs_elixir_core,
  service_name:               "mi-microservicio",
  ecs_elixir_enable_sampling: false,
  ecs_elixir_enable_masking:  false
```

### 2. Registrar errores y éxitos desde el controlador

```elixir
alias EcsElixirCore.Infra.EntryPoints.RestApi.Application.HandlerEcsRest

# Log de error — solo campos de negocio; la librería extrae el resto del conn
HandlerEcsRest.log(
  %{
    code:        "ER404-00",
    detail:      "Recurso no encontrado.",
    category:    "BEX_ECS_BUG",
    log_code:    "ER404-00-01",
    log_message: "No se encontró el recurso solicitado.",
    status:      404,
    error:       nil
  },
  conn,
  message_id
)

# Log de éxito
HandlerEcsRest.log_success(result, conn, message_id)
```

### 3. JSON emitido

**Error:**

```json
{
  "message-id": "a1b2c3d4-...",
  "date": "03/06/2026 20:19:36:0675",
  "service": "mi-microservicio",
  "consumer": "APP-MOBILE",
  "level": "ERROR",
  "additionalInfo": {
    "method": "POST",
    "uri": "/tickets/sell",
    "headers": {
      "content-type": "application/json",
      "consumer": "APP-MOBILE",
      "message-id": "a1b2c3d4-..."
    },
    "requestBody": { "ticket_id": "T999", "quantity": 1 },
    "responseBody": null,
    "responseResult": "Not Found",
    "responseCode": "404"
  },
  "error": {
    "type":         "ER404-00-01",
    "message":      "Recurso no encontrado.",
    "description":  "No se encontró el recurso solicitado.",
    "optionalInfo": null
  }
}
```

**Éxito:**

```json
{
  "message-id": "a1b2c3d4-...",
  "date": "03/06/2026 20:19:36:0450",
  "service": "mi-microservicio",
  "consumer": "APP-MOBILE",
  "level": "INFO",
  "additionalInfo": {
    "method": "POST",
    "uri": "/tickets/sell",
    "headers": { "consumer": "APP-MOBILE", "content-type": "application/json" },
    "requestBody":  { "ticket_id": "T001", "quantity": 2 },
    "responseBody": { "sale_id": "uuid-...", "status": "CONFIRMED" },
    "responseResult": "OK",
    "responseCode":   "200"
  }
}
```

---

## Configuración

### Básica

```elixir
config :ecs_elixir_core,
  service_name:               "mi-microservicio",
  ecs_elixir_enable_sampling: false,
  ecs_elixir_enable_masking:  false
```

### Con sampling habilitado

El sampling permite reducir el volumen de logs en endpoints de alta frecuencia. Se configuran reglas por URI y código de respuesta:

```elixir
config :ecs_elixir_core,
  service_name:               "mi-microservicio",
  ecs_elixir_enable_sampling: true,
  ecs_elixir_enable_masking:  false,
  sampling_source_app:        :mi_app,
  sampling_source_key:        :ecs_sampling

config :mi_app, :ecs_sampling,
  rules20XJson: ~s([
    {"uri": "/health",       "responseCode": "200", "showCount": 1, "skipCount": 9},
    {"uri": "/tickets/sell", "responseCode": "200", "showCount": 1, "skipCount": 4}
  ]),
  rules40XJson: ~s([
    {"uri": "/tickets/sell", "responseCode": "404",
     "errorCodes": "ER404-00|ER404-01", "showCount": 1, "skipCount": 4}
  ])
```

Cuando el sampling está habilitado, agregar el GenServer al árbol de supervisión en `application.ex`:

```elixir
alias EcsElixirCore.Infra.DrivenAdapters.EtsGenServer.Features.Sampling.Infra.EtsSamplingGenServer

def start(_type, _args) do
  children = [
    EtsSamplingGenServer,
    # ...
  ]
  Supervisor.start_link(children, strategy: :one_for_one, name: MiApp.Supervisor)
end
```

---

## Uso

### Log de error — `HandlerEcsRest.log/3`

```elixir
HandlerEcsRest.log(error_map, conn, message_id)
```

| Campo | Tipo | Descripción |
|---|---|---|
| `code` | `String` | Código de error de negocio |
| `detail` | `String` | Mensaje para el usuario |
| `category` | `String` | Categoría del error |
| `log_code` | `String` | Código de trazabilidad del log |
| `log_message` | `String` | Mensaje interno del log |
| `status` | `integer` | Código de estado HTTP |
| `error` | `any` | Excepción original (se captura en `optionalInfo`) |

### Log de éxito — `HandlerEcsRest.log_success/3`

```elixir
HandlerEcsRest.log_success(result, conn, message_id)
```

`result` puede ser cualquier término — se serializa e incluye en `responseBody`.

### Normalización de `message-id`

La librería acepta cualquiera de estas variantes y siempre emite la clave canónica `"message-id"`:

```elixir
HandlerEcsRest.log(error, conn, "abc-123")   # string explícito
HandlerEcsRest.log(error, conn, nil)          # extraído automáticamente del conn
# Nombres de header aceptados: message-id, messageId, messageid, message_id
```

---

## Niveles de log

| Nivel      | Función Logger     | Cuándo usarlo                          |
|------------|--------------------|----------------------------------------|
| `DEBUG`    | `Logger.debug`     | Trazabilidad en desarrollo             |
| `INFO`     | `Logger.info`      | Respuestas exitosas, eventos normales  |
| `WARNING`  | `Logger.warning`   | Errores recuperables                   |
| `ERROR`    | `Logger.error`     | Errores de negocio controlados         |
| `CRITICAL` | `Logger.critical`  | Fallas graves                          |

---

## Ejemplo completo en un controlador

```elixir
defmodule MiApp.TicketController do
  use MiApp, :controller

  alias EcsElixirCore.Infra.EntryPoints.RestApi.Application.HandlerEcsRest

  def sell(conn, params) do
    message_id = conn |> get_req_header("message-id") |> List.first()

    case MiApp.TicketService.sell(params) do
      {:ok, result} ->
        HandlerEcsRest.log_success(result, conn, message_id)
        conn |> put_status(:ok) |> json(result)

      {:error, :not_found} ->
        error = %{
          code:        "ER404-00",
          detail:      "Recurso no encontrado.",
          category:    "ERROR",
          log_code:    "ER404-00-01",
          log_message: "No se encontró el ticket solicitado.",
          status:      404,
          error:       nil
        }
        HandlerEcsRest.log(error, conn, message_id)
        conn |> put_status(:not_found) |> json(%{error: error.detail})

      {:error, exception} ->
        error = %{
          code:        "SAER500-29",
          detail:      "Ha ocurrido un error inesperado.",
          category:    "ERROR",
          log_code:    "SAER500-29-01",
          log_message: "Error inesperado al procesar la venta.",
          status:      500,
          error:       exception
        }
        HandlerEcsRest.log(error, conn, message_id)
        conn |> put_status(:internal_server_error) |> json(%{error: error.detail})
    end
  end
end
```

---

## Arquitectura

```
lib/
├── application/
│   ├── ecs_middleware/middleware_ecs_config.ex      Lee service_name y features
│   └── features/
│       ├── sampling/sampling_config.ex              Cachea reglas de sampling
│       └── masking/masking_config.ex                Configuración de masking
│
├── domain/
│   ├── model/
│   │   ├── ecs_middleware/
│   │   │   ├── model/
│   │   │   │   ├── core_exception.ex
│   │   │   │   ├── ecs_constant.ex
│   │   │   │   ├── ecs_payload.ex
│   │   │   │   └── log_record.ex                    JSON con "message-id" canónico
│   │   │   └── value/
│   │   │       ├── core_exception_field_validator.ex
│   │   │       ├── core_exception_level_validator.ex
│   │   │       └── log_record_error.ex
│   │   └── features/sampling/
│   │       ├── model/sampling_rule_set.ex
│   │       └── value/
│   │           ├── sampling_error_code.ex
│   │           ├── sampling_rule.ex
│   │           └── sampling_rule_validator.ex
│   ├── shared/
│   │   ├── logging/internal_logging.ex              Log interno de la librería
│   │   └── model/common/
│   │       ├── ecs_command.ex                       Comando entre entry point y use case
│   │       └── message_id.ex                        Normaliza variantes de message-id
│   └── use_case/
│       ├── ecs_middleware/
│       │   ├── ecs_core_usecase.ex
│       │   ├── ecs_app_rest_usecase.ex
│       │   └── ports/log_writer_port.ex             Behaviour para adaptadores de escritura
│       └── features/sampling/sampling_usecase.ex
│
└── infra/
    ├── driven_adapters/
    │   ├── cmd_line/middleware_ecs/application/
    │   │   └── cmd_line_logger_ecs.ex               Implementa LogWriterPort
    │   └── ets_gen_server/features/sampling/infra/
    │       └── ets_sampling_gen_server.ex            Contador ETS para sampling
    └── entry_points/rest_api/
        ├── application/handler_ecs_rest.ex           API pública: log/3 y log_success/3
        └── domain/
            ├── ecs_response.ex                       Error → EcsPayload
            └── success_log.ex                        Éxito → EcsPayload
```

---

## Desarrollo local

```bash
git clone https://github.com/bancolombia/ecs-elixir-core.git
cd ecs-elixir-core
mix deps.get
mix test
mix coveralls.html   # reporte de cobertura
mix credo --strict   # análisis estático
mix dialyzer         # verificación de tipos
```

---

## Dependencias

| Librería  | Versión  | Uso                          |
|-----------|----------|------------------------------|
| `jason`   | `~> 1.4` | Serialización JSON           |
| `timex`   | `~> 3.7` | Zona horaria Bogotá          |
| `uuid`    | `~> 1.1` | Generación de `message-id`   |

---

## Licencia

Apache 2.0 — ver [LICENSE](https://github.com/bancolombia/ecs-elixir-core/blob/main/LICENSE).