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, basic_req_resp_info.
- Permite controlar la impresión de request desde configuración.
- 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,
  ecs_elixir_enable_http_req_error: true,
  ecs_elixir_enable_http_req_success: false,
  ecs_elixir_enable_http_resp: true,
  ecs_elixir_http_resp_length: 200
```

### 2. Habilitar Plug en el microservicio

En tu endpoint/router Plug-Phoenix agrega el plug para realizar la impresión de errores y éxitos de forma automática:

```elixir
plug EcsElixirCore.Infra.EntryPoints.PlugHandler.Application.HandlerEcsResponse
```

Y en `application.ex` agrega estos children al supervisor:

```elixir
children = [
  EcsElixirCore.Supervisor,
  # ...otros children
]
```

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

Realiza el registro de forma manual de error o éxitos desde código.

```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)
```

### 4. 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,
  ecs_elixir_enable_http_req_error: true,
  ecs_elixir_enable_http_req_success: false,
  ecs_elixir_enable_http_resp: true,
  ecs_elixir_http_resp_length: 200,
  ecs_elixir_enable_basic_req_resp_info: false
```

### Caracteristicas habilitables (flags reales)

| Clave | Default | Aplica en | Efecto |
|---|---|---|---|
| `ecs_elixir_enable_sampling` | `false` | REST + Plug | Activa evaluación de sampling antes de escribir el log |
| `ecs_elixir_enable_masking` | `false` | REST + Plug | Activa el enmascaramiento de campos sensibles en la información adicional del log |
| `ecs_elixir_enable_http_req_error` | `false` | REST | Cuando `false`, elimina campos de request en logs de error |
| `ecs_elixir_enable_http_req_success` | `false` | Plug | Cuando `false`, elimina campos de request en logs de exito |
| `ecs_elixir_enable_http_resp` | `false` | Plug | Cuando `false`, elimina `responseBody` del log |
| `ecs_elixir_http_resp_length` | `200` | Plug | Limite maximo (1..200) para truncar `responseBody` serializado |
| `ecs_elixir_enable_basic_req_resp_info` | `false` | Plug | Controla si se emite el log cuando el evento no contiene `requestBody` ni `responseBody`. Ver [sección detallada](#con-basic-info-habilitado) |

### Claves de sampling avanzado

```elixir
config :ecs_elixir_core,
  sampling_source_app: :mi_app,
  sampling_source_key: :ecs_sampling
```

### 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_http_req_error: true,
  ecs_elixir_enable_http_req_success: false,
  ecs_elixir_enable_http_resp: true,
  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, se debe de garantizar que en árbol de `application.ex` este configurado el supervisor de la librería:

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

### Con basic info habilitado

El flag `ecs_elixir_enable_basic_req_resp_info` controla si los eventos de Plug **sin cuerpo** (`requestBody` y `responseBody` vacíos o ausentes) deben emitirse o descartarse silenciosamente.

**Comportamiento del pipeline:**

| `ecs_elixir_enable_basic_req_resp_info` | `requestBody` / `responseBody` | ¿Se emite el log? |
|---|---|---|
| `false` (default) | ambos ausentes/vacíos | ❌ descartado (`skip`) |
| `false` | al menos uno presente | ✅ emitido |
| `true` | cualquier combinación | ✅ siempre emitido |

**Cuándo usarlo:**
- Actívalo (`true`) si necesitas auditar **cada** petición, incluso llamadas a endpoints como `/health` o `/ping` que no tienen cuerpos relevantes.
- Déjalo en `false` (default) para reducir el volumen de logs cuando el evento no aporta información de negocio en el cuerpo.

**Ejemplo de configuración:**

```elixir
config :ecs_elixir_core,
  service_name: "mi-microservicio",
  ecs_elixir_enable_basic_req_resp_info: true
```

> **Nota:** Este flag opera en el pipeline Plug (`EcsAppPlugUseCase`). Si el log es descartado por este feature, devuelve `:ok` sin emitir nada — el comportamiento es idéntico al de `sampling` en modo skip.

---

### El enmascaramiento permite ocular o eliminar el contenido sensible dentro de la información adicional que se incluye en los logs generados:

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

config :mi_app, :ecs_masking,
  masking_rules_json: ~s([
    {
      "masking_uri_patterns": "/tickets/sell",
      "masking_fields": ["requestBody.*", "password", "requestBody.users.name"],
      "masking_char": "*",
      "masking_type": "custom",
      "masking_percentage": 0.5,
      "masking_custom_placeholder": "[MASKED]",
      "masking_length: 10
    }
    ])
```

 El contenido de la propiedad `masking_rules_json` debe de ser un Json con la parametrización requerida, por lo que un formato valido es un Json string con las reglas a definir por ejemplo: 
```
"[{\"masking_uri_patterns\":\"/tickets/sell\",\"masking_fields\":[\"requestBody.*\",\"password\",\"requestBody.users.name\"],\"masking_char\":\"*\",\"masking_type\":\"custom\",\"masking_percentage\":0.5,\"masking_custom_placeholder\":\"[MASKED]\",\"masking_length:10}]"
```

#### Configuración
| Propiedad | Requerido| Tipo | Default | Descripción | 
|--|--|--|--|--|
| `masking_uri_patterns` | Sí | String |--| Path al cual se le aplicará la regla de enmascaramiento, se admite el uso del comodín `*`, ej: `/tickets/*` |
| `masking_fields` | Sí | List |--| Lista de campos a los cuales se les realizara el enmascaramiento, se admite el uso del comodín `*` |
| `masking_char` | No | Char | * | Caracter con el cual sera reemplazado el texto sensible |
| `masking_type` | No | String | full | Tipo de enmascaramiento a aplicar, valores admitidos: full, partial, custom, remove |
| `masking_percentage` | No | Float | 0.7| Porcentaje de enmascaramiento sobre el texto sensible a aplicar cuando el tipo de enmascarado es partial |
| `masking_custom_placeholder` | No | String | [MASKED]| Texto personalizada por el cual es reemplazado el texto sensible cuando el tipo de enmascarado es custom |
| `masking_length` | No | Integer | 8 | Cantidad de caracteres con los que aparecerán los campos sensibles que han sido enmascarados cuando el tipo de enmascaramiento es full |

**Tipo enmascaramiento**

 - **full**: Reeamplaza el contenido del texto sensible en su totalidad por el caracter definido en `masking_char` y su tamaño corresponderá siempre a la longitud definida en `masking_length`.
 - **partical**: Reemplaza parcialmente el centido del texto sensible por el caracter definido en `masking_char` y la porción reemplazada corresponde al porcentaje definido en `masking_percentage`
 - **custom**: Reeamplaza el texto sensible por la palabra definida en `masking_custom_placeholder`.
 - **remove**: Elimina el campo con el texto sensible del log

**Ejemplos de patrones validos**

Enmascarar todos los campos del `additionalInfo` que sean del tipo password
```
"masking_fields": ["password"]
```

```json
{
    ...
    "additionalInfo": {
        "uri": "/tickets/sell",
        "requestBody": {
            "password": "***",
            "quantity": "1"
        },
        "responseCode": "200",
        "responseBody": {
            "user": "Doe",
            "password": "***",
            "admin": {
                "user": "Doe",
                "password": "***"
            }
        },
        "responseResult": "OK",
        "method": "POST"
    }
}
```

Enmascarar el campo password asociado al admin en la respuesta del servicio
```

"masking_fields": ["responseBody.admin.password"]
```

```
{
    ...
    "additionalInfo": {
        "uri": "/tickets/sell",
        "requestBody": {
            "password": "123",
            "quantity": "1"
        },
        "responseCode": "200",
        "responseBody": {
            "user": "Doe",
            "password": "123",
            "admin": {
                "user": "Doe",
                "password": "***"
            }
        },
        "responseResult": "OK",
        "method": "POST"
    }
}
```

Enmascarar todos los campo asociados al admin

```
"masking_fields": ["responseBody.admin.*"]
```

```
{
    ...
    "additionalInfo": {
        "uri": "/tickets/sell",
        "requestBody": {
            "password": "123",
            "quantity": "1"
        },
        "responseCode": "200",
        "responseBody": {
            "user": "Doe",
            "password": "123",
            "admin": {
                "user": "***",
                "password": "***"
            }
        },
        "responseResult": "OK",
        "method": "POST"
    }
}
```

Enmascarar el campo password de la lista de usuarios
```

"masking_fields": ["responseBody.users.password"]
```

```
{
    ...
    "additionalInfo": {
        "uri": "/tickets/sell",
        "requestBody": {
            "password": "123",
            "quantity": "1"
        },
        "responseCode": "200",
        "responseBody": {
            "users": [
                {
                    "user": "Doe",
                    "password": "***"
                },
                {
                    "user": "Doe2",
                    "password": "***"
                }
            ]
        },
        "responseResult": "OK",
        "method": "POST"
    }
}
```

---

## 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                 Config REST/base
│   │   └── plug_logger_config.ex                    Config + async logger para Plug
│       └── features/
│           ├── masking/masking_config.ex                Configuracion de masking
│           ├── basic_info/basic_info_config.ex          Flag de visibilidad basic info
│           ├── request/request_config.ex                Flags de request (error/success)
│           ├── response/response_config.ex              Flag de response body
│           └── sampling/sampling_config.ex              Cachea reglas de sampling
│
├── domain/
│   ├── model/
│   │   ├── ecs_middleware/
│   │   │   ├── model/
│   │   │   │   ├── core_exception.ex
│   │   │   │   ├── ecs_payload.ex
│   │   │   │   └── log_record.ex                    JSON con "message-id" canónico
│   │   │   └── value/
│   │   │       ├── core_exception_input_validator.ex
│   │   │       ├── core_exception_level_validator.ex
│   │   │       ├── ecs_build_log_error_constant.ex
│   │   │       ├── ecs_default_error_constant.ex
│   │   │       ├── ecs_log_level.ex
│   │   │       ├── ecs_log_level_constant.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/
│   │   ├── common/
│   │   │   ├── model/message_id.ex                  Normaliza variantes de message-id
│   │   │   └── value/
│   │   │       ├── command.ex                       Comando entre entry point y use case
│   │   │       └── context_data.ex                  Contexto tecnico de ejecucion
│   │   └── logging/internal_logging.ex              Log interno de la libreria
│   └── use_case/
│       ├── ecs_middleware/
│       │   ├── ecs_core_usecase.ex
│       │   ├── ecs_app_rest_usecase.ex
│       │   └── ecs_app_plug_usecase.ex
│       └── features/
│           ├── request/
│           │   ├── request_error_usecase.ex
│           │   └── request_success_usecase.ex
│           ├── basic_info/basic_info_usecase.ex
│           ├── response/response_usecase.ex
│           └── 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/
        ├── plug_handler/
        │   ├── application/handler_ecs_response.ex   Plug para logging de responses
        │   └── domain/success_response.ex            Response Plug -> EcsPayload
        └── rest_api/
            ├── application/handler_ecs_rest.ex       API publica: log/3 y log_success/3
            └── domain/
                ├── ecs_response.ex                   Error -> EcsPayload
                └── success_log.ex                    Exito -> 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                          |
|-----------|----------|------------------------------|
| `plug`    | `~> 1.15`| Habilitar propiedades sobre Plug |
| `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).