Metadata-Version: 2.4
Name: jhlogger
Version: 1.2.1
Summary: A feature-rich, configurable logging module with structured JSON output
Author-email: JH Dev <dev@jacarandahealth.org>
License: MIT
Project-URL: Homepage, https://github.com/Jacaranda-Health/jhlogger
Project-URL: Bug Tracker, https://github.com/Jacaranda-Health/jhlogger/issues
Project-URL: Documentation, https://github.com/Jacaranda-Health/jhlogger/blob/main/README.md
Project-URL: Source Code, https://github.com/Jacaranda-Health/jhlogger
Keywords: logging,json,structured,cloudwatch,sentry,configurable,traceback,debug,monitoring,opentelemetry,temporal
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Logging
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: OS Independent
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: structlog>=23.0.0
Requires-Dist: watchtower>=3.0.0
Requires-Dist: sentry-sdk>=1.0.0
Provides-Extra: otel
Requires-Dist: opentelemetry-api>=1.0.0; extra == "otel"
Provides-Extra: temporal
Requires-Dist: temporalio>=1.0.0; extra == "temporal"
Provides-Extra: observability
Requires-Dist: opentelemetry-api>=1.0.0; extra == "observability"
Requires-Dist: temporalio>=1.0.0; extra == "observability"
Provides-Extra: dev
Requires-Dist: black>=24.8.0; extra == "dev"
Requires-Dist: flake8>=5.0.4; extra == "dev"
Requires-Dist: flake8-simplify>=0.22.0; extra == "dev"
Requires-Dist: isort>=6.0.1; extra == "dev"
Requires-Dist: pre-commit>=3.5.0; extra == "dev"
Requires-Dist: pytest>=8.3.5; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: mypy>=1.14.1; extra == "dev"
Provides-Extra: docs
Requires-Dist: sphinx>=5.0.0; extra == "docs"
Requires-Dist: sphinx-rtd-theme>=1.0.0; extra == "docs"
Dynamic: license-file

# JH Logger

[![Python Support](https://img.shields.io/badge/python-3.9%2B-blue)](https://python.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)

A configurable logging module that emits structured JSON with error tracking,
third-party service integration, and request-scoped context. Built for
Jacaranda Health's microservices ecosystem.

**New in v1.2.0**: a shared `get_logger()` registry, request context via
`structlog.contextvars` merged by default, `capture_stdlib_logging()` for
third-party log capture, a de-duplicated output schema, and a `py.typed`
marker so mypy sees the library's type hints. Configuration now follows one
rule everywhere (constructor beats environment beats default), every
deployment-facing option has an environment variable, log calls can no
longer crash the caller, and the console stream, CloudWatch stream name,
and caller-frame skip list are configurable (constructor-only).
See [CHANGELOG.md](CHANGELOG.md) for breaking changes and
deprecations.

## 🌟 Features

### ✅ **Configurable Log Levels**

- Support for all standard log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
- Dynamic log level changes at runtime
- Enum-based level specification for type safety
- Unknown level names fail fast with `InvalidLogLevel` (a `ValueError`
  subclass)

### ✅ **Structured JSON Output**

- Compact one-line JSON in production/staging, pretty JSON elsewhere
- One copy of each fact: `message`, `level`, `timestamp`, `service`
- Consistent structure across all log entries with sorted keys
- Support for arbitrary data fields
- A log call never raises: non-string dictionary keys and unserializable
  values (datetimes, sets, custom objects) degrade to strings

### ✅ **Rich Context Information**

- Request-scoped context (`structlog.contextvars`) merged into every entry
- System information (country, environment, process ID)
- UTC timestamp in ISO format
- Caller information (file, function, class, line number) — on by default in
  development and test, opt-in for deployed environments
- Caller paths are relative to the working directory, never absolute

### ✅ **Error Tracking**

- `error(exception=...)` / `critical(exception=...)` capture the exception
  type, message, and traceback
- Exceptions are mirrored to Sentry when Sentry is enabled

### ✅ **Third-Party Service Integration**

- **CloudWatch**: Optional direct CloudWatch logging with environment-variable
  controls; setup failures are reported on stderr instead of being swallowed
- **Sentry**: Exception capturing for ERROR/CRITICAL levels
- **Stdlib capture**: `capture_stdlib_logging()` renders uvicorn/sqlalchemy/
  boto3 records as the same JSON as your own entries

### ✅ **Built-in Observability Integration**

- **OpenTelemetry**: Automatic `trace_id` and `span_id` injection when spans
  are active
- **Temporal**: Automatic workflow/activity context (`workflow_id`,
  `activity_type`, `task_queue`, `attempt`)
- **Enhanced Bound Logger**: Preserves the full jhlogger API after `bind()`

## 📦 Installation

```bash
# Basic installation
pip install jhlogger

# With OpenTelemetry support
pip install jhlogger[otel]

# With Temporal support
pip install jhlogger[temporal]

# With full observability stack
pip install jhlogger[observability]
```

### Development Installation

```bash
git clone https://github.com/Jacaranda-Health/jhlogger.git
cd jhlogger
uv sync --all-extras

# Or with pip
pip install -e .[dev]
```

## 🚀 Quick Start

### Basic Usage

```python
import jhlogger

# One shared logger per name — repeated calls return the same instance,
# like logging.getLogger. Configuration applies on first creation only.
logger = jhlogger.get_logger("my-service", environment="production", country="KE")

logger.info("Application started successfully")
logger.error("Something went wrong", data={"error_code": "E001"})

# With exception
try:
    result = 10 / 0
except Exception as e:
    logger.error("Division failed", exception=e, data={"operation": "10/0"})
```

Module-level convenience functions (`jhlogger.info(...)`, `jhlogger.error(...)`)
log through the default logger and are handy in scripts.

### `data=` versus keyword arguments

Fields passed via `data=` are nested under the `data` key of the entry; bare
keyword arguments land at the top level:

```python
logger.info("mixed", data={"inner": 1}, outer=2)
# ... {"data": {"inner": 1}, "outer": 2, ...}
```

Pick one style per service so log queries stay predictable.

### Request context

Anything bound with `structlog.contextvars` appears in every entry until it
is cleared — ideal for request IDs in web middleware:

```python
import structlog

structlog.contextvars.bind_contextvars(request_id="req-42")
logger.info("processing request")   # includes "request_id": "req-42"
structlog.contextvars.clear_contextvars()
```

### Capturing third-party logs

```python
import logging
import jhlogger

logger = jhlogger.get_logger("my-service")
jhlogger.capture_stdlib_logging(logger, level=logging.WARNING)
# uvicorn, sqlalchemy, boto3 warnings now come out as the same JSON,
# with "logger" naming the record's origin.
```

### Observability Integration

```python
import jhlogger

# Logger with built-in OpenTelemetry and Temporal context injection
logger = jhlogger.get_logger(
    "my-service",
    enable_otel_context=True,      # Automatic trace_id/span_id injection
    enable_temporal_context=True,  # Automatic workflow/activity context
    json_indent=None,              # Compact JSON for production
)

# Enhanced bound logger (preserves full API)
request_logger = logger.bind(request_id="req_123", user_id=456)
request_logger.info("Processing request")  # Includes request_id and user_id
request_logger.error("Request failed", exception=e)  # Full jhlogger API preserved

# Unbind context
unbound_logger = request_logger.unbind("request_id")
```

**Sample output with OpenTelemetry and Temporal context:**
```json
{
    "activity_type": "process_message",
    "attempt": 1,
    "level": "info",
    "message": "Processing workflow step",
    "service": "my-service",
    "span_id": "cdbe9e6318742af9",
    "task_queue": "messages_queue_KE",
    "timestamp": "2026-03-25T19:00:16.570964Z",
    "trace_id": "8f0de49b683ccf048b470d109025df6e",
    "workflow_id": "messages-02223f6f-0c14-4853-aa31-3708f527ff3e"
}
```

## 📊 Log Output Format

### Standard Log Entry (development: pretty JSON, caller included)

```json
{
  "caller": {
    "class": "UserService",
    "file_path": "app/services/user_service.py",
    "filename": "user_service.py",
    "function": "login_user",
    "line_number": 45,
    "module": "user_service"
  },
  "country": "KE",
  "data": {
    "ip_address": "192.168.1.100",
    "user_id": "12345",
    "username": "john_doe"
  },
  "environment": "development",
  "level": "info",
  "message": "User logged in successfully",
  "process_id": 72458,
  "service": "user-service",
  "timestamp": "2026-07-31T12:05:06.270499Z"
}
```

In production and staging the same entry is a compact single line and the
`caller` block is omitted unless explicitly enabled.

### ERROR Level (includes exception details)

```json
{
  "country": "KE",
  "environment": "development",
  "exception": {
    "message": "Unable to connect to database",
    "traceback": [
      "Traceback (most recent call last):\n",
      "  File \"app.py\", line 25, in connect_db\n",
      "ConnectionError: Unable to connect to database\n"
    ],
    "type": "ConnectionError"
  },
  "level": "error",
  "message": "Database connection failed",
  "process_id": 72458,
  "service": "user-service",
  "timestamp": "2026-07-31T12:05:06.271002Z"
}
```

## ⚙️ Configuration

### One resolution rule

Every option resolves the same way:

1. An **explicit constructor argument** wins.
2. An **environment variable** fills the gap when the argument is unset.
3. A **built-in default** applies otherwise.

The single exception is `DISABLE_CLOUDWATCH=true`, an emergency kill switch
that beats everything, including explicit constructor arguments.

### Constructor Parameters

```python
import jhlogger
from jhlogger import LogLevel

jhlogger.get_logger(
    "service-name",                         # Emitted as the "service" field
    log_level=LogLevel.INFO,                # Minimum log level
    environment="production",               # production/staging/development/test
    country="KE",                           # Omitted from entries when unset
    enable_cloudwatch=True,                 # Default: on iff a log group is set
    cloudwatch_log_group="custom-group",    # CloudWatch log group
    cloudwatch_stream_name="api-1",         # Default: "<name>-<pid>"
    cloudwatch_kwargs={"send_interval": 15},  # Extra watchtower options
    enable_sentry=True,                     # Sentry exception capture
    include_system_info=True,               # environment/country/process_id
    include_caller_info=None,               # Env-aware caller metadata default
    custom_processors=[],                   # Additional structlog processors
    json_indent=None,                       # None=compact, int=pretty
    enable_otel_context=True,               # OpenTelemetry trace_id/span_id
    enable_temporal_context=True,           # Temporal workflow/activity context
    enable_contextvars=True,                # Merge structlog.contextvars
    enable_console=True,                    # Write entries to the stream
    stream=None,                            # Console stream; default sys.stdout
    extra_skip_modules=["logging_utils"],   # Skip wrappers in caller detection
)
```

`ConfigurableLogger(...)` accepts the same parameters for callers that need a
private, unshared instance.

By default, production and staging use compact one-line JSON and omit caller
metadata. Development and test environments keep pretty JSON and caller
metadata for easier debugging.

The module-level convenience functions (`jhlogger.info(...)` and friends) use
the shared default logger. Configure it once, before the first call, with
`jhlogger.get_logger("jhlogger", ...)`.

### Environment Variables

Each variable applies only when the corresponding constructor argument is
unset. The `JHLOGGER_*` names are canonical; the bare names are legacy
spellings kept for existing Jacaranda services and may collide with other
tools in a shared environment.

| Variable | Meaning |
|---|---|
| `JHLOGGER_LOG_LEVEL` or `LOG_LEVEL` | Minimum log level (DEBUG ... CRITICAL) |
| `APP_ENV` or `FLASK_ENV` | Deployment environment (production, staging, development, test) |
| `COUNTRY` | Country code; the field is omitted when unset |
| `JHLOGGER_JSON_INDENT` | JSON indentation; `none`, `null`, or `compact` for one-line JSON |
| `JHLOGGER_COMPACT_JSON` or `LOG_COMPACT_JSON` | Boolean toggle for compact JSON output |
| `JHLOGGER_INCLUDE_CALLER` or `LOG_INCLUDE_CALLER` | Boolean toggle for caller metadata |
| `JHLOGGER_INCLUDE_SYSTEM_INFO` or `LOG_INCLUDE_SYSTEM_INFO` | Boolean toggle for system metadata |
| `JHLOGGER_ENABLE_SENTRY` | Boolean toggle for Sentry mirroring |
| `JHLOGGER_ENABLE_OTEL_CONTEXT` | Boolean toggle for OpenTelemetry context |
| `JHLOGGER_ENABLE_TEMPORAL_CONTEXT` | Boolean toggle for Temporal context |
| `JHLOGGER_ENABLE_CONTEXTVARS` | Boolean toggle for contextvars merging |
| `JHLOGGER_CLOUDWATCH_LOG_GROUP` | CloudWatch log group name |
| `JHLOGGER_ENABLE_CLOUDWATCH` or `ENABLE_DIRECT_CLOUDWATCH_LOGS` | Boolean toggle for direct CloudWatch shipping |
| `DISABLE_CLOUDWATCH` | Kill switch: `true` disables CloudWatch, beating everything |

Unparseable values (for example `JHLOGGER_JSON_INDENT=pretty` or
`JHLOGGER_ENABLE_SENTRY=maybe`) are reported on stderr and skipped, so a
deployment typo degrades to the default instead of silently flipping a flag.

### Reserved output keys

The logger's own processors own these keys: `message`, `level`, `timestamp`,
and `service`, plus `environment`, `country`, and `process_id` when system
info is enabled. A keyword field using one of those names is overwritten,
and a keyword field named `event` (structlog's internal name for the
message) is dropped. The log message itself is positional-only —
`logger.info(message="...")` is a `TypeError`; write `logger.info("...")`.

### Deprecations (removal in 2.0)

- `warn(...)` — use `warning(...)`.
- `jhlogger.Logger` / `jhlogger.BoundLogger` aliases — use
  `ConfigurableLogger` / `BoundConfigurableLogger`.
- `ConfigurableLogger.get_logger()` and `BoundConfigurableLogger.get_logger()`
  methods — use the `logger` attribute/property. (The module-level
  `jhlogger.get_logger()` registry function is the recommended entry point
  and is not deprecated.)
- The `app_env` attribute — use `environment`.
- `enable_bugsnag` — deprecated and ignored; Bugsnag support was never
  implemented.

Each of these emits a `DeprecationWarning` when used.

## 🔧 Advanced Usage

### Bound Context Logging

```python
# Bind context that applies to all subsequent logs
bound_logger = logger.bind(
    request_id="req_123",
    session_id="sess_456"
)

bound_logger.info("Processing request")           # Includes bound context
bound_logger.error("Request failed", exception=e)  # Full error handling preserved

# Chain binding and unbinding
user_logger = bound_logger.bind(user_id=789)
final_logger = user_logger.unbind("session_id")
```

### Dynamic Log Level Changes

```python
import jhlogger
from jhlogger import LogLevel

logger = jhlogger.get_logger("my-service", log_level=LogLevel.WARNING)

logger.info("This won't show")  # Below WARNING level
logger.set_level(LogLevel.DEBUG)
logger.info("Now this will show")  # Now visible
```

### Structured Data Logging

```python
complex_data = {
    "user_profile": {
        "id": "usr_123",
        "name": "Jane Doe",
        "roles": ["admin", "user"]
    },
    "request_info": {
        "method": "POST",
        "endpoint": "/api/users",
        "duration_ms": 156
    }
}

logger.info("User operation completed", data=complex_data)
```

### Multiple Logger Instances

Prefer `get_logger(name)`: it returns one shared instance per name. Directly
constructed instances with the same name still work independently — each
maintains its own log level and handlers:

```python
from jhlogger import ConfigurableLogger, LogLevel

logger1 = ConfigurableLogger(name="app", log_level=LogLevel.DEBUG)
logger2 = ConfigurableLogger(name="app", log_level=LogLevel.WARNING)

logger1.debug("Debug message")    # Appears (DEBUG level)
logger2.debug("Debug message")    # Filtered (WARNING level)

logger1.set_level(LogLevel.CRITICAL)
logger2.warning("Still works")    # Unaffected by logger1
```

Both show the same `service` name in log output. Note that every direct
construction registers a permanent internal stdlib logger, which is why the
shared `get_logger()` registry is the recommended entry point.

## 🔒 Security Features

- **Relative File Paths**: Caller metadata uses paths relative to the working
  directory (or the bare filename) to avoid exposing system information
- **Configurable Information**: Control what system information is included
- **Robust Error Handling**: Sentry/CloudWatch failures never crash the app

## 🧪 Development & Testing

```bash
# Clone the repository
git clone https://github.com/Jacaranda-Health/jhlogger.git
cd jhlogger

# Install with UV (recommended)
uv sync --all-extras

# Install pre-commit hooks
pre-commit install
```

### Available Commands

```bash
make format   # Format code (black + isort)
make lint     # flake8 + mypy
make test     # pytest with coverage (report in htmlcov/)
make check    # Read-only gate: format checks + lint + test
make clean    # Remove build artifacts
```

## 📋 Development Standards

- **Code Formatting**: Black (100 character line length)
- **Import Sorting**: isort (Black-compatible profile)
- **Linting**: flake8 with flake8-simplify
- **Type Checking**: mypy (the package ships a `py.typed` marker)
- **Testing**: pytest with coverage reporting
- **Pre-commit Hooks**: Automated formatting and linting

## 📄 License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

## 🤝 Support

- 📫 **Issues**: [GitHub Issues](https://github.com/Jacaranda-Health/jhlogger/issues)
- 📖 **Documentation**: [README.md](https://github.com/Jacaranda-Health/jhlogger/blob/main/README.md)
- 🔗 **Repository**: [GitHub Repository](https://github.com/Jacaranda-Health/jhlogger)

## 🌍 Real-World Integration Example

JHLogger runs in production Temporal workflows with full OpenTelemetry
tracing. Here's a real example from a message processing system:

```python
import jhlogger

logger = jhlogger.get_logger(
    "temporal-message-processor",
    enable_otel_context=True,      # Automatic trace context
    enable_temporal_context=True,  # Automatic workflow context
    json_indent=None,              # Compact for production
)

def process_messages_workflow():
    workflow_logger = logger.bind(country="KE", operation="message_processing")
    workflow_logger.info("Starting message processing workflow")
    # Automatically includes: trace_id, span_id, workflow_id, task_queue
```

**Production output:**
```json
{
    "country": "KE",
    "level": "info",
    "message": "Starting message processing workflow",
    "operation": "message_processing",
    "service": "temporal-message-processor",
    "span_id": "cdbe9e6318742af9",
    "task_queue": "messages_queue_KE",
    "timestamp": "2026-03-25T19:00:16.570964Z",
    "trace_id": "8f0de49b683ccf048b470d109025df6e",
    "workflow_id": "messages-02223f6f-0c14-4853-aa31-3708f527ff3e-KE"
}
```

## 🏷️ Changelog

See [CHANGELOG.md](CHANGELOG.md) for the full release history.

---

**Made with ❤️ for Jacaranda Health's logging needs**
