ECS Elixir Core

Hex.pmDocsLicenciaCI

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:

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

Luego 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:


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: 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:

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

Y 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: false

Caracteristicas habilitables (flags reales)

ClaveDefaultAplica enEfecto
ecs_elixir_enable_samplingfalseREST + PlugActiva evaluación de sampling antes de escribir el log
ecs_elixir_enable_maskingfalseREST + PlugActiva el enmascaramiento de campos sensibles en la información adicional del log
ecs_elixir_enable_http_req_errorfalseRESTCuando false, elimina campos de request en logs de error
ecs_elixir_enable_http_req_successfalsePlugCuando false, elimina campos de request en logs de exito
ecs_elixir_enable_http_respfalsePlugCuando false, elimina responseBody del log
ecs_elixir_http_resp_length200PlugLimite maximo (1..200) para truncar responseBody serializado
ecs_elixir_enable_basic_req_resp_infofalsePlugControla 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_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:

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)
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_inforequestBody / responseBody¿Se emite el log?
false (default)ambos ausentes/vacíos❌ descartado (skip)
falseal menos uno presente✅ emitido
truecualquier combinación✅ siempre emitido

Cuándo usarlo:

Ejemplo de configuración:

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:

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

PropiedadRequeridoTipoDefaultDescripción
masking_uri_patternsString--Path al cual se le aplicará la regla de enmascaramiento, se admite el uso del comodín *, ej: /tickets/*
masking_fieldsList--Lista de campos a los cuales se les realizara el enmascaramiento, se admite el uso del comodín *
masking_charNoChar*Caracter con el cual sera reemplazado el texto sensible
masking_typeNoStringfullTipo de enmascaramiento a aplicar, valores admitidos: full, partial, custom, remove
masking_percentageNoFloat0.7Porcentaje de enmascaramiento sobre el texto sensible a aplicar cuando el tipo de enmascarado es partial
masking_custom_placeholderNoString[MASKED]Texto personalizada por el cual es reemplazado el texto sensible cuando el tipo de enmascarado es custom
masking_lengthNoInteger8Cantidad de caracteres con los que aparecerán los campos sensibles que han sido enmascarados cuando el tipo de enmascaramiento es full

Tipo enmascaramiento

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)
CampoTipoDescripción
codeStringCódigo de error de negocio
detailStringMensaje para el usuario
categoryStringCategoría del error
log_codeStringCódigo de trazabilidad del log
log_messageStringMensaje interno del log
statusintegerCódigo de estado HTTP
erroranyExcepció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_id

Niveles de log

NivelFunción LoggerCuándo usarlo
DEBUGLogger.debugTrazabilidad en desarrollo
INFOLogger.infoRespuestas exitosas, eventos normales
WARNINGLogger.warningErrores recuperables
ERRORLogger.errorErrores de negocio controlados
CRITICALLogger.criticalFallas 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
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

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íaVersiónUso
plug~> 1.15Habilitar propiedades sobre Plug
jason~> 1.4Serialización JSON
timex~> 3.7Zona horaria Bogotá
uuid~> 1.1Generación de message-id

Licencia

Apache 2.0 — ver LICENSE.