# ECS Elixir Core
[](https://hex.pm/packages/ecs_elixir_core)
[](https://hexdocs.pm/ecs_elixir_core)
[](https://github.com/bancolombia/ecs-elixir-core/blob/main/LICENSE)
[](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).