ECS Elixir Core
Copy MarkdownLibrerí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:
def deps do
[
{:ecs_elixir_core, "~> 1.0"}
]
endLuego ejecuta:
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-idy contexto HTTP delconn— el microservicio no los pasa manualmente. - Normaliza el campo
message-iddesde 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íaecs-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:
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: 2002. 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:
plug EcsElixirCore.Infra.EntryPoints.PlugHandler.Application.HandlerEcsResponseY en application.ex agrega estos children al supervisor:
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.
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:
{
"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:
{
"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
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: falseCaracteristicas 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 |
Claves de sampling avanzado
config :ecs_elixir_core,
sampling_source_app: :mi_app,
sampling_source_key: :ecs_samplingCon 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:
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:
def start(_type, _args) do
children = [
EcsElixirCore.Supervisor,
# ...
]
Supervisor.start_link(children, strategy: :one_for_one, name: MiApp.Supervisor)
endCon 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/healtho/pingque 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:
config :ecs_elixir_core,
service_name: "mi-microservicio",
ecs_elixir_enable_basic_req_resp_info: trueNota: Este flag opera en el pipeline Plug (
EcsAppPlugUseCase). Si el log es descartado por este feature, devuelve:oksin emitir nada — el comportamiento es idéntico al desamplingen modo skip.
El enmascaramiento permite ocular o eliminar el contenido sensible dentro de la información adicional que se incluye en los logs generados:
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_chary su tamaño corresponderá siempre a la longitud definida enmasking_length. - partical: Reemplaza parcialmente el centido del texto sensible por el caracter definido en
masking_chary la porción reemplazada corresponde al porcentaje definido enmasking_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"]{
...
"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
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
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":
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_idNiveles 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
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
endArquitectura
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 -> EcsPayloadDesarrollo local
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.