Metadata-Version: 2.4
Name: jhlogger
Version: 1.2.0
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 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. 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
{
    "trace_id": "8f0de49b683ccf048b470d109025df6e",
    "span_id": "cdbe9e6318742af9",
    "workflow_id": "messages-02223f6f-0c14-4853-aa31-3708f527ff3e",
    "activity_type": "process_message",
    "task_queue": "messages_queue_KE",
    "attempt": 1,
    "message": "Processing workflow step",
    "level": "info",
    "service": "my-service",
    "timestamp": "2026-03-25T19:00:16.570964Z"
}
```

## 📊 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.
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
{
    "trace_id": "8f0de49b683ccf048b470d109025df6e",
    "span_id": "cdbe9e6318742af9",
    "workflow_id": "messages-02223f6f-0c14-4853-aa31-3708f527ff3e-KE",
    "task_queue": "messages_queue_KE",
    "country": "KE",
    "operation": "message_processing",
    "message": "Starting message processing workflow",
    "level": "info",
    "service": "temporal-message-processor",
    "timestamp": "2026-03-25T19:00:16.570964Z"
}
```

## 🏷️ Changelog

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

---

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