README.md

# Elixir ECS Library

A comprehensive Elixir library that enables structured logging in ECS (Elastic Common Schema) format, providing standardized log output for better observability and monitoring in Elixir applications.

## Installation

Add `ecs_logs_elixir` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:ecs_logs_elixir, , "~> 0.1.0"}
  ]
end
```

Then run:

```bash
mix deps.get
```

## Configuration

### Service Configuration

Configure your service name, sampling_source_app, and sampling_source_key in `config/config.exs`:

```elixir
config :ecs_logs_elixir,
  service_name: "my_application",
  sampling_source_app: :my_application,
  sampling_source_key: :ecs_sampling
```

- `service_name`: name of the service reported in the ECS payload. If not defined, the default value is `"INDEFINIDO"`.
- `sampling_source_app`: name of the application where the sampling configuration is stored. Default: `:ecs_logs_elixir`.
- `sampling_source_key`: configuration key used to read sampling rules. Default: `:ecs_sampling`.

## Sampling

It allows you to reduce the number of ECS logs written for selected requests instead of logging every matching event. Sampling rules are loaded from the application defined in `sampling_source_app` and the key defined in `sampling_source_key`.

Each sampling rule contains:

- `uri`: endpoint path to match.
- `responseCode`: HTTP response code used to classify the rule.
- `showCount`: number of matching logs to print.
- `skipCount`: number of matching logs to skip.
- `errorCodes`: pipe-separated error codes used only for `40X` rules.

### Sampling Configuration

In environment-specific files such as `config/dev.exs`, `config/test.exs`, and `config/pdn.exs`, define the sampling rules under the application and key configured by `sampling_source_app` and `sampling_source_key`.

Minimal structure:

```elixir
# sampling
config :my_application, :ecs_sampling,
  rules20XJson: "[{\"uri\":\"/signin\",\"responseCode\":\"200\",\"showCount\":1,\"skipCount\":1}]",
  rules40XJson: "[{\"uri\":\"/signin\",\"responseCode\":\"401\",\"showCount\":1,\"skipCount\":1,\"errorCodes\":\"ER-401\"},{\"uri\":\"/signup\",\"responseCode\":\"409\",\"showCount\":1,\"skipCount\":3,\"errorCodes\":\"ER-409\"}]"
```

### `rules20XJson`

Use this variable to define sampling rules for endpoints that return `20X` HTTP status codes.

Valid JSON value:

```json
[
  {
    "uri": "/signin",
    "responseCode": "200",
    "showCount": 1,
    "skipCount": 1
  }
]
```

This rule means that for `/signin` with HTTP `200`, the library prints `1` log and skips `1` log. In practice, matching requests are logged 50% of the time.

How it must look inside Elixir config:

```elixir
rules20XJson: "[{\"uri\":\"/signin\",\"responseCode\":\"200\",\"showCount\":1,\"skipCount\":1}]"
```

You can also use an empty string or an empty JSON array if you do not want sampling rules for `20X` responses.

### `rules40XJson`

Use this variable to define sampling rules for endpoints that return `40X` HTTP status codes.

Valid JSON value:

```json
[
  {
    "uri": "/signin",
    "responseCode": "401",
    "showCount": 1,
    "skipCount": 1,
    "errorCodes": "ER-401"
  },
  {
    "uri": "/signup",
    "responseCode": "409",
    "showCount": 1,
    "skipCount": 3,
    "errorCodes": "ER-409"
  },
  {
    "uri": "/signup",
    "responseCode": "400",
    "showCount": 1,
    "skipCount": 1,
    "errorCodes": "ER-400"
  },
  {
    "uri": "/signin",
    "responseCode": "404",
    "showCount": 1,
    "skipCount": 1,
    "errorCodes": "ER-404"
  }
]
```

These rules define sampling for `40X` responses using the derived error code. For example, `/signup` with derived error code `ER-409` prints `1` log and skips `3`, so matching requests are logged 25% of the time.

How it must look inside Elixir config:

```elixir
rules40XJson: "[{\"uri\":\"/signin\",\"responseCode\":\"401\",\"showCount\":1,\"skipCount\":1,\"errorCodes\":\"ER-401\"},{\"uri\":\"/signup\",\"responseCode\":\"409\",\"showCount\":1,\"skipCount\":3,\"errorCodes\":\"ER-409\"},{\"uri\":\"/signup\",\"responseCode\":\"400\",\"showCount\":1,\"skipCount\":1,\"errorCodes\":\"ER-400\"},{\"uri\":\"/signin\",\"responseCode\":\"404\",\"showCount\":1,\"skipCount\":1,\"errorCodes\":\"ER-404\"}]"
```

You can also use an empty string or an empty JSON array if you do not want sampling rules for `40X` responses.

Because both values are JSON arrays stored as strings, double quotes must be escaped inside Elixir config.

### Sampling Runtime Behavior

For `20X` responses:

- Rules are matched with the key `"#{uri}|#{responseCode}"`.
- `errorCodes` must not be configured. If present, it must be empty.

For `40X` responses:

- Rules are matched with the key `"#{uri}|#{derived_error_code}"`.
- `errorCodes` is required and must contain one or more pipe-separated values.
- The derived error code is built from the first two segments of `internal_error_code`.
- Example: `"ER-409-01-01"` becomes `"ER-409"`.

General behavior:

- `cycle = showCount + skipCount`.
- A log is printed when the current counter position is lower than `showCount`.
- Counters rotate within the configured cycle for each rule key.
- If no matching rule is found, the log is printed.
- If sampling configuration is missing, invalid, or cannot be parsed, the log is printed.
- Any response not covered by a configured rule is logged normally.

### Sampling Validation Notes

- `rules20XJson` only accepts rules whose `responseCode` starts with `20`.
- `rules40XJson` only accepts rules whose `responseCode` starts with `40`.
- `showCount` and `skipCount` must be non-negative integers.
- `showCount + skipCount` must be greater than `0`.
- An empty string or an empty JSON array means no sampling rules are applied for that group.

## Usage

### Basic Logging

```elixir
# Simple error logging
ElixirEcsLogger.log_ecs(%{
  error_code: "USER_001",
  error_message: "User validation failed",
  additional_info: %{
    uri: "/users",
    responseCode: 400
  }
})

# Logging with additional details
ElixirEcsLogger.log_ecs(%{
  error_code: "DB_001",
  error_message: "Database connection timeout",
  level: "ERROR",
  internal_error_code: "CONN_TIMEOUT",
  internal_error_message: "Connection to database timed out after 30 seconds",
  additional_details: %{
    database: "users_db",
    timeout: 30000,
    retry_count: 3
  },
  message_id: "msg_12345",
  consumer: "user_service",
  additional_info: %{
    uri: "/users",
    responseCode: 500
  }
})
```

### Log Levels

The library supports the following log levels:

- `"DEBUG"`: detailed information for debugging.
- `"INFO"`: general information messages.
- `"WARNING"`: warning messages for potential issues.
- `"ERROR"`: error messages for handled exceptions.
- `"CRITICAL"`: critical errors that may cause application failure.

```elixir
# Debug level logging
ElixirEcsLogger.log_ecs(%{
  error_code: "DEBUG_001",
  error_message: "Processing user request",
  level: "DEBUG",
  additional_info: %{
    uri: "/users",
    responseCode: 200
  }
})

# Critical level logging
ElixirEcsLogger.log_ecs(%{
  error_code: "CRIT_001",
  error_message: "Database connection lost",
  level: "CRITICAL",
  internal_error_code: "DB-500-01",
  internal_error_message: "Primary database is unreachable",
  additional_info: %{
    uri: "/users",
    responseCode: 500
  }
})
```

### Full Example

```elixir
defmodule MyApp.UserService do
  def create_user(params) do
    case validate_user(params) do
      {:ok, user} ->
        ElixirEcsLogger.log_ecs(%{
          error_code: "USER_CREATED",
          error_message: "User successfully created",
          level: "INFO",
          message_id: generate_message_id(),
          consumer: "user_service",
          additional_details: %{user_id: user.id},
          additional_info: %{
            uri: "/users",
            responseCode: 201
          }
        })

        {:ok, user}

      {:error, reason} ->
        ElixirEcsLogger.log_ecs(%{
          error_code: "USER_VALIDATION_FAILED",
          error_message: "User validation failed",
          level: "ERROR",
          internal_error_code: "VALIDATION_ERROR",
          internal_error_message: inspect(reason),
          additional_details: %{params: params},
          message_id: generate_message_id(),
          consumer: "user_service",
          additional_info: %{
            uri: "/users",
            responseCode: 400
          }
        })

        {:error, reason}
    end
  end
end
```

## Application Integration

Add ECS logging in both your global response and error handlers.

### Success Handler

```elixir
require ElixirEcsLogger

log_success(response, conn, headers, request_body)

defp log_success(%{status: status, body: response_body}, conn, headers, request_body) do
  payload = build_ecs_payload(conn, status, headers, request_body, response_body)
  ElixirEcsLogger.log_ecs(payload)
end

defp build_ecs_payload(conn, status, headers, request_body, response_body) do
  %{
    error_code: "",
    error_message: "",
    level: "INFO",
    internal_error_code: "",
    internal_error_message: "",
    additional_details: "",
    message_id: Map.get(headers, "message_id", ""),
    consumer: nil,
    additional_info: build_additional_info(conn, status, headers, request_body, response_body)
  }
end

defp build_additional_info(conn, status, headers, body, response_body) do
  %{
    method: conn.method,
    uri: conn.request_path,
    headers: headers,
    requestBody: body,
    responseBody: response_body,
    responseResult: "OK",
    responseCode: status
  }
end
```

### Error Handler

```elixir
require ElixirEcsLogger

@status_descriptions %{
  400 => "Bad Request",
  401 => "Unauthorized",
  404 => "Not Found",
  409 => "Conflict",
  500 => "Internal Server Error"
}

log_error(exception_data, conn, headers, body)

defp log_error(exception_data, conn, headers, body) do
  payload = build_ecs_payload(exception_data, conn, headers, body)
  ElixirEcsLogger.log_ecs(payload)
end

defp build_ecs_payload(exception_data, conn, headers, body) do
  %{
    error_code: exception_data.code,
    error_message: exception_data.detail,
    level: "ERROR",
    internal_error_code: exception_data.log_code,
    internal_error_message: exception_data.log_message,
    additional_details: "",
    message_id: Map.get(headers, "message_id", ""),
    consumer: nil,
    additional_info: build_additional_info(conn, exception_data.status, headers, body)
  }
end

defp build_additional_info(conn, status, headers, body) do
  %{
    method: conn.method,
    uri: conn.request_path,
    headers: headers,
    requestBody: body,
    responseResult: status_description(status),
    responseCode: status
  }
end

defp status_description(status), do: Map.get(@status_descriptions, status, "Unknown Error")
```

## API Documentation

### ElixirEcsLogger.log_ecs/1

Logs a structured message in ECS format.

**Parameters:**
- `attrs` (map) - Logging attributes

**Required attributes:**
- `error_code` (string) - Unique error code identifier. Required for `ERROR`, `WARNING`, and `CRITICAL` levels.
- `error_message` (string) - Human-readable error message. Required for `ERROR`, `WARNING`, and `CRITICAL` levels.
- `additional_info` (map) - Contextual data included in the ECS payload. For sampling decisions, `additional_info.uri` and `additional_info.responseCode` are required.

**Optional attributes:**
- `level` (string) - Log level (defaults to "ERROR")
- `internal_error_code` (string) - Internal system error code
- `internal_error_message` (string) - Internal system error message
- `additional_details` (any) - Additional context information
- `message_id` (string) - Unique message identifier
- `consumer` (string) - Service or component that generated the log

**Returns:**
- `:ok` - Successfully logged
- `{:error, reason}` - Error occurred during logging

## Log Output Format

The library generates JSON logs with the following structure:

```json
{
  "messageId": "12345",
  "date": "29/10/2025 17:48:55.734000",
  "service": "my_application",
  "consumer": "user_service",
  "additionalInfo": {
    "uri": "/users",
    "responseCode": 400
  },
  "level": "ERROR",
  "error": {
    "type": "VALIDATION_ERROR",
    "message": "User validation failed",
    "description": "Email format is invalid",
    "optionalInfo": {
      "field": "email",
      "value": "invalid-email"
    }
  }
}
```

For non-error levels such as `INFO` and `DEBUG`, the `error` object is omitted from the final JSON payload.

## Development

### Running Tests

```bash
# Run all tests
mix test

# Run with coverage
mix coveralls.html
```

### Code Quality

```bash
# Run code formatter
mix format

# Run static analysis
mix credo

# Run dialyzer
mix dialyzer
```

## Contributing

Contributions are welcome.

1. Fork the repository.
2. Create a feature branch.
3. Make your changes.
4. Add tests for new functionality.
5. Ensure tests and checks pass.
6. Open a Pull Request.

### Development Guidelines

- Follow Elixir naming conventions.
- Write tests for new behavior.
- Update documentation when the public API changes.
- Ensure code passes formatting and static analysis checks.
- Add typespecs for public functions where appropriate.