# Story 10.6: Enhanced Default Behaviors

## Status: Complete

## Priority: High

## Estimated Effort: Medium (2-3 days)

## Dependencies

- **Depends on:** Story 10.1 (Configuration Presets) - Builds on preset system
- **Depends on:** Story 10.2 (Pretty Console Output) - Uses format detection
- **Blocks:** Story 10.8 (Smart Environment Auto-Detection) - Provides default selection helpers

## Epic / Series

Part of Epic 10: Developer Experience and Ergonomics / Series 10.x

---

## Context / Background

Users should get sensible logging defaults without configuration. This story provides **simple, environment-agnostic defaults** that work well in most cases. For advanced environment-specific auto-detection, see Story 10.8.

### What's Already Implemented ✅

The following default behaviors already exist and **do not need implementation**:

1. **Output format auto-detection** - ✅ **Story 10.2 Complete**
   - `format="auto"` detects TTY and uses pretty/JSON accordingly
   - Implementation: `_resolve_format()` in `src/fapilog/__init__.py`
   - No work needed for this story

2. **Context enrichment** - ✅ **Already Implemented**
   - `RuntimeInfoEnricher` adds: `service`, `env`, `version`, `host`, `pid`, `python`
   - Implementation: `src/fapilog/plugins/enrichers/runtime_info.py`
   - No work needed for this story

3. **Security defaults** - ✅ **Already Implemented**
   - Production preset includes redaction (9 fields)
   - Implementation: `src/fapilog/core/presets.py`
   - No work needed for this story

4. **Kubernetes enricher** - ✅ **Already Implemented**
   - `KubernetesEnricher` exists and adds pod/namespace/node metadata
   - Implementation: `src/fapilog/plugins/enrichers/kubernetes.py`
   - Note: Story 10.8 will auto-enable this; this story doesn't need to

### What This Story Implements 🆕

This story focuses on **gaps** that need implementation:

1. **Log level defaults** - ❌ **Not Implemented**
   - Current: Always `INFO` (from `CoreSettings.log_level`)
   - Need: `DEBUG` for TTY, `INFO` otherwise
   - Need: CI detection forces `INFO` even if TTY

2. **Sink failure fallback** - ❌ **Not Implemented**
   - Current: Errors contained but logs may be lost
   - Need: Fallback to stderr when primary sink fails
   - Need: Diagnostics warning when fallback used

3. **Default selection helpers** - ❌ **Not Implemented**
   - Need: Reusable helpers for Story 10.8 to use
   - Need: `is_ci_environment()`, `is_tty_environment()`, `get_default_log_level()`

## Scope (In / Out)

### In Scope

- **Log level defaults** based on TTY detection (DEBUG for interactive, INFO otherwise).
- **CI environment detection** for log level (INFO in CI even if TTY).
- **Sink failure fallback** mechanism (fallback to stderr when primary sink fails).
- **Default selection helpers** for use by Story 10.8.

### Out of Scope

- **Output format defaults** - Already implemented in Story 10.2 (`format="auto"`).
- **Context enrichment defaults** - Already implemented (RuntimeInfoEnricher exists).
- **Security defaults** - Already implemented (production preset has redaction).
- **Environment auto-detection** - Deferred to Story 10.8 (Docker, Kubernetes, Lambda detection).
- **Auto-enabling enrichers** - Deferred to Story 10.8 (environment-based enricher selection).
- **New sink types** or external integrations.

## Acceptance Criteria

### AC1: Log Level Defaults Based on TTY

**Log level automatically adjusts based on terminal context:**

```python
# Interactive TTY (development)
logger = get_logger()  # log_level defaults to DEBUG

# Non-TTY (production, scripts, pipes)
logger = get_logger()  # log_level defaults to INFO
```

**Behavior:**
- TTY detected: `log_level="DEBUG"` (more verbose for development)
- Non-TTY: `log_level="INFO"` (appropriate for production/scripts)
- Explicit `log_level` in Settings always overrides
- Preset `log_level` always overrides (e.g., `preset="production"` → INFO)

### AC2: CI Environment Detection for Log Level

**CI environments force INFO level even if TTY:**

```python
# In CI (even if TTY)
export CI=true
logger = get_logger()  # log_level defaults to INFO (not DEBUG)
```

**CI detection checks:**
- `CI=true` (standard CI environment variable)
- `GITHUB_ACTIONS` (GitHub Actions)
- `JENKINS_URL` (Jenkins)
- `GITLAB_CI` (GitLab CI)
- `CIRCLECI` (CircleCI)
- `TRAVIS` (Travis CI)
- `TEAMCITY_VERSION` (TeamCity)

**Priority:**
1. Explicit `log_level` in Settings
2. Preset `log_level`
3. CI detection (forces INFO)
4. TTY detection (DEBUG if TTY, INFO otherwise)

### AC3: Sink Failure Fallback Mechanism

**When primary sink fails, fallback to stderr with warning:**

```python
# Primary sink (e.g., file) fails
# Automatically falls back to stderr
# Emits diagnostics warning
# No log loss
```

**Fallback behavior:**
- Primary sink write fails → try stderr
- Stderr write fails → emit diagnostics warning only
- No exceptions raised to user code
- Diagnostics warning includes: sink name, error type, fallback used
- Fallback is transparent (user doesn't need to handle)

**Fallback order:**
1. Primary configured sink(s)
2. stderr (emergency fallback)
3. Diagnostics warning only (if stderr also fails)

**Note**: Fallback only triggers on write failures, not initialization failures.
**Note**: Current sink contract contains errors; fallback only triggers when a sink
raises. Reliable failure signaling will be addressed in a follow-up story.

### AC4: Default Selection Helpers

**Helper functions for Story 10.8 to use:**

```python
def get_default_log_level(*, is_tty: bool, is_ci: bool) -> str:
    """Get default log level based on context."""
    if is_ci:
        return "INFO"
    return "DEBUG" if is_tty else "INFO"

def is_ci_environment() -> bool:
    """Detect if running in CI environment."""
    # Check standard CI env vars
    ...

def should_fallback_sink(primary_failed: bool) -> bool:
    """Determine if sink fallback should be used."""
    ...
```

**Purpose**: Provide reusable helpers that Story 10.8 can use for environment detection.

### AC5: Tests Validate Defaults and Fallback

- Test: TTY detection sets DEBUG level
- Test: Non-TTY sets INFO level
- Test: CI detection overrides TTY (INFO even if TTY)
- Test: Explicit log_level overrides defaults
- Test: Preset log_level overrides defaults
- Test: Sink failure triggers stderr fallback
- Test: Fallback emits diagnostics warning
- Test: No exceptions raised during fallback

## Implementation Notes

### File Structure

```
src/fapilog/core/defaults.py (NEW)
  - get_default_log_level()
  - is_ci_environment()
  - is_tty_environment()

src/fapilog/core/settings.py (MODIFIED)
  - Update CoreSettings.log_level default logic

src/fapilog/plugins/sinks/fallback.py (NEW) or modify routing.py
  - Fallback sink mechanism
  - Stderr fallback sink

tests/unit/test_defaults.py (NEW)
tests/unit/test_sink_fallback.py (NEW)
```

### Implementation Approach

**1. Log Level Defaults:**

```python
# src/fapilog/core/defaults.py
def get_default_log_level(*, is_tty: bool | None = None, is_ci: bool | None = None) -> str:
    """Get default log level based on context.
    
    Priority:
    1. CI detection (forces INFO)
    2. TTY detection (DEBUG if TTY, INFO otherwise)
    """
    if is_ci is None:
        is_ci = is_ci_environment()
    if is_ci:
        return "INFO"
    
    if is_tty is None:
        is_tty = is_tty_environment()
    return "DEBUG" if is_tty else "INFO"

def is_ci_environment() -> bool:
    """Detect CI environment from standard env vars."""
    ci_vars = [
        "CI",
        "GITHUB_ACTIONS",
        "JENKINS_URL",
        "GITLAB_CI",
        "CIRCLECI",
        "TRAVIS",
        "TEAMCITY_VERSION",
    ]
    return any(os.getenv(var) for var in ci_vars)

def is_tty_environment() -> bool:
    """Detect if stdout is a TTY."""
    try:
        return sys.stdout.isatty()
    except Exception:
        return False
```

**2. Sink Fallback:**

```python
# src/fapilog/plugins/sinks/fallback.py
class FallbackSink:
    """Wrapper sink that falls back to stderr on failure."""
    
    def __init__(self, primary_sink: Any):
        self._primary = primary_sink
        self._fallback_count = 0
    
    async def write(self, entry: dict[str, Any]) -> None:
        try:
            await self._primary.write(entry)
        except Exception as exc:
            # Fallback to stderr
            try:
                await self._write_to_stderr(entry)
                self._fallback_count += 1
                if self._fallback_count == 1:  # First fallback
                    from ..core.diagnostics import warn
                    warn(
                        "sink",
                        "primary sink failed, using stderr fallback",
                        sink=type(self._primary).__name__,
                        error=type(exc).__name__,
                    )
            except Exception:
                # Even stderr failed - just emit diagnostics
                from ..core.diagnostics import warn
                warn(
                    "sink",
                    "all sinks failed, log entry lost",
                    sink=type(self._primary).__name__,
                    error=type(exc).__name__,
                )
    
    async def _write_to_stderr(self, entry: dict[str, Any]) -> None:
        """Write entry to stderr as JSON."""
        import json
        import sys
        line = json.dumps(entry, separators=(",", ":")) + "\n"
        sys.stderr.write(line)
        sys.stderr.flush()
```

**3. Integration with Settings:**

```python
# In get_logger() / get_async_logger()
# When no explicit log_level and no preset:
if settings is None and preset is None:
    from .core.defaults import get_default_log_level
    default_level = get_default_log_level()
    # Apply default level
```

**Assumption**: Explicit settings always override defaults.

## Tasks

### Phase 1: Default Selection Helpers

- [ ] Create `src/fapilog/core/defaults.py`
- [ ] Implement `get_default_log_level()` function
- [ ] Implement `is_ci_environment()` function
- [ ] Implement `is_tty_environment()` function
- [ ] Add comprehensive docstrings with examples
- [ ] Export from `src/fapilog/core/__init__.py`

### Phase 2: Log Level Defaults Integration

- [ ] Update `get_logger()` to use default log level when no explicit setting
- [ ] Update `get_async_logger()` to use default log level when no explicit setting
- [ ] Ensure explicit `log_level` in Settings overrides defaults
- [ ] Ensure preset `log_level` overrides defaults
- [ ] Test priority: explicit > preset > CI > TTY

### Phase 3: Sink Fallback Mechanism

- [ ] Create `src/fapilog/plugins/sinks/fallback.py` OR modify routing
- [ ] Implement `FallbackSink` wrapper class
- [ ] Implement stderr fallback sink
- [ ] Integrate fallback into sink write path
- [ ] Add diagnostics warnings for fallback usage
- [ ] Ensure no exceptions raised to user code

### Phase 4: Testing

- [ ] Create `tests/unit/test_defaults.py`
- [ ] Test `get_default_log_level()` with all combinations
- [ ] Test `is_ci_environment()` with various CI env vars
- [ ] Test `is_tty_environment()` with TTY and non-TTY
- [ ] Create `tests/unit/test_sink_fallback.py`
- [ ] Test fallback triggers on primary sink failure
- [ ] Test fallback emits diagnostics warning
- [ ] Test no exceptions raised during fallback
- [ ] Integration test: log level defaults in actual logger creation

### Phase 5: Documentation

- [ ] Update `docs/user-guide/configuration.md` with default behaviors
- [ ] Document log level default behavior
- [ ] Document sink fallback behavior
- [ ] Add examples showing defaults vs. explicit settings
- [ ] Update `CHANGELOG.md`

## Tests

### Unit Tests (`tests/unit/test_defaults.py`)

```python
"""Unit tests for default selection helpers."""

import os
import sys
from unittest.mock import patch

import pytest

from fapilog.core.defaults import (
    get_default_log_level,
    is_ci_environment,
    is_tty_environment,
)


class TestGetDefaultLogLevel:
    """Test get_default_log_level function."""

    def test_tty_returns_debug(self):
        """TTY environment returns DEBUG."""
        assert get_default_log_level(is_tty=True, is_ci=False) == "DEBUG"

    def test_non_tty_returns_info(self):
        """Non-TTY environment returns INFO."""
        assert get_default_log_level(is_tty=False, is_ci=False) == "INFO"

    def test_ci_overrides_tty(self):
        """CI environment forces INFO even if TTY."""
        assert get_default_log_level(is_tty=True, is_ci=True) == "INFO"

    def test_auto_detects_tty(self):
        """Auto-detects TTY when not provided."""
        with patch("sys.stdout.isatty", return_value=True):
            assert get_default_log_level(is_ci=False) == "DEBUG"

    def test_auto_detects_ci(self):
        """Auto-detects CI when not provided."""
        with patch.dict(os.environ, {"CI": "true"}):
            assert get_default_log_level(is_tty=True) == "INFO"


class TestIsCiEnvironment:
    """Test CI environment detection."""

    def test_ci_var_detected(self):
        """Standard CI variable detected."""
        with patch.dict(os.environ, {"CI": "true"}):
            assert is_ci_environment() is True

    def test_github_actions_detected(self):
        """GitHub Actions detected."""
        with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}):
            assert is_ci_environment() is True

    def test_jenkins_detected(self):
        """Jenkins detected."""
        with patch.dict(os.environ, {"JENKINS_URL": "http://jenkins"}):
            assert is_ci_environment() is True

    def test_no_ci_vars_returns_false(self):
        """No CI variables returns False."""
        with patch.dict(os.environ, {}, clear=True):
            assert is_ci_environment() is False


class TestIsTtyEnvironment:
    """Test TTY environment detection."""

    def test_tty_detected(self):
        """TTY detected."""
        with patch("sys.stdout.isatty", return_value=True):
            assert is_tty_environment() is True

    def test_non_tty_detected(self):
        """Non-TTY detected."""
        with patch("sys.stdout.isatty", return_value=False):
            assert is_tty_environment() is False

    def test_isatty_exception_returns_false(self):
        """Exception in isatty returns False."""
        with patch("sys.stdout.isatty", side_effect=Exception):
            assert is_tty_environment() is False
```

### Unit Tests (`tests/unit/test_sink_fallback.py`)

```python
"""Unit tests for sink fallback mechanism."""

import json
import sys
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from fapilog.plugins.sinks.fallback import FallbackSink


class TestFallbackSink:
    """Test FallbackSink wrapper."""

    @pytest.mark.asyncio
    async def test_primary_sink_success(self):
        """Primary sink success - no fallback."""
        primary = AsyncMock()
        fallback = FallbackSink(primary)
        
        entry = {"message": "test"}
        await fallback.write(entry)
        
        primary.write.assert_called_once_with(entry)
        assert fallback._fallback_count == 0

    @pytest.mark.asyncio
    async def test_primary_failure_falls_back_to_stderr(self):
        """Primary sink failure triggers stderr fallback."""
        primary = AsyncMock(side_effect=Exception("Sink failed"))
        fallback = FallbackSink(primary)
        
        entry = {"message": "test"}
        
        with patch("sys.stderr.write") as stderr_write:
            await fallback.write(entry)
            
            # Should write to stderr
            stderr_write.assert_called_once()
            written = stderr_write.call_args[0][0]
            assert json.loads(written.strip()) == entry
            assert fallback._fallback_count == 1

    @pytest.mark.asyncio
    async def test_fallback_emits_warning(self):
        """Fallback emits diagnostics warning."""
        primary = AsyncMock(side_effect=Exception("Sink failed"))
        fallback = FallbackSink(primary)
        
        with patch("fapilog.core.diagnostics.warn") as warn_mock:
            with patch("sys.stderr.write"):
                await fallback.write({"message": "test"})
                
                # Should emit warning on first fallback
                warn_mock.assert_called_once()
                assert "fallback" in str(warn_mock.call_args).lower()

    @pytest.mark.asyncio
    async def test_stderr_failure_emits_warning_only(self):
        """Stderr failure emits warning but doesn't raise."""
        primary = AsyncMock(side_effect=Exception("Sink failed"))
        fallback = FallbackSink(primary)
        
        with patch("sys.stderr.write", side_effect=Exception("Stderr failed")):
            with patch("fapilog.core.diagnostics.warn") as warn_mock:
                # Should not raise
                await fallback.write({"message": "test"})
                
                # Should emit warning about log loss
                assert warn_mock.called
```

### Integration Tests

- Test: Logger created with default log level (DEBUG in TTY, INFO otherwise)
- Test: Logger created with CI detection (INFO even if TTY)
- Test: Explicit log_level overrides defaults
- Test: Preset log_level overrides defaults
- Test: Sink failure in real logger triggers fallback
- Test: Fallback doesn't break logger functionality

## Definition of Done

Story is complete when ALL of the following are true:

### Code Complete
- [ ] All acceptance criteria met and verified
- [ ] All tasks completed
- [ ] Code follows project style guide
- [ ] No linting errors or warnings
- [ ] Type checking passes (mypy strict)

### Quality Assurance
- [ ] Unit tests: >90% coverage of new code
- [ ] Integration tests: all scenarios passing
- [ ] Regression tests: no existing functionality broken
- [ ] Performance tests: no regression vs baseline
- [ ] Manual testing completed (if applicable)

### Documentation
- [ ] User-facing docs updated
- [ ] API reference updated (if applicable)
- [ ] Code examples added/updated
- [ ] CHANGELOG.md updated
- [ ] Inline code documentation complete

### Review & Release
- [ ] Code review approved
- [ ] Documentation reviewed for clarity
- [ ] CI/CD pipeline passing
- [ ] Ready for merge to main branch

### Backwards Compatibility
- [ ] No breaking changes OR breaking changes documented with migration guide
- [ ] Existing tests still pass
- [ ] Deprecation warnings added (if applicable)

---

## Risks / Rollback / Monitoring

### Risks

- **Risk:** TTY detection misclassifies (e.g., IDE terminal vs. script)
  - **Mitigation:** Conservative detection, explicit `log_level` always overrides
  - **Mitigation:** Clear documentation that defaults are suggestions, not requirements

- **Risk:** CI detection false positives (user sets `CI=true` for testing)
  - **Mitigation:** Explicit `log_level` always overrides
  - **Mitigation:** Document CI detection behavior

- **Risk:** Fallback behavior causes confusion or performance issues
  - **Mitigation:** Clear diagnostics warnings when fallback used
  - **Mitigation:** Document fallback behavior and when it triggers
  - **Mitigation:** Fallback is transparent (no user code changes needed)

- **Risk:** Stderr fallback pollutes error output
  - **Mitigation:** Fallback only on write failures, not initialization
  - **Mitigation:** Diagnostics warning alerts user to investigate

### Rollback Plan

- Keep helper isolated
- Allow opt-out via explicit settings
- Defaults can be reverted easily

### Success Metrics / Monitoring

- Track warnings emitted for fallback paths (via diagnostics)
- Monitor default log level selection (DEBUG vs INFO)
- User feedback on default behavior appropriateness
- Track CI detection accuracy (if telemetry added)

---

## Relationship to Other Stories

### Story 10.6 (This Story) - Simple Defaults

**Provides:**
- Log level defaults (DEBUG for TTY, INFO otherwise)
- CI detection for log level
- Sink failure fallback mechanism
- Default selection helpers (`is_ci_environment()`, `is_tty_environment()`)

**Does NOT provide:**
- Environment detection (Docker, Kubernetes, Lambda) - See Story 10.8
- Auto-enabling enrichers - See Story 10.8
- Output format detection - Already done in Story 10.2

### Story 10.8 - Full Auto-Detection

**Builds on Story 10.6:**
- Uses `is_ci_environment()` helper from Story 10.6
- Uses `is_tty_environment()` helper from Story 10.6
- Uses `get_default_log_level()` helper from Story 10.6

**Adds:**
- Full environment detection (Docker, Kubernetes, Lambda)
- Auto-enabling enrichers based on environment
- Environment-specific configuration profiles

**Boundary:**
- Story 10.6 = Simple, environment-agnostic defaults
- Story 10.8 = Advanced, environment-specific auto-configuration

## Related Documents

- [Story 10.0: Series Overview](./10.0-series-overview.md)
- [Story 10.1: Configuration Presets](./10.1.configuration-presets.md)
- [Story 10.2: Pretty Console Output](./10.2.pretty-console-output.md) - ✅ Complete
- [Story 10.8: Smart Environment Auto-Detection](./10.8.smart-environment-auto-detection.md) - Depends on this story
- [Epic 10: Developer Experience](../prd/epic-10-DX-Experience.md)

---

## Change Log

| Date       | Change                    | Author |
| ---------- | ------------------------- | ------ |
| 2025-01-10 | Migrated to new format    | System |
