|
1 | 1 | # pylint:disable=redefined-outer-name |
2 | 2 | # pylint:disable=unused-argument |
3 | 3 |
|
| 4 | +import io |
4 | 5 | import logging |
| 6 | +import re |
5 | 7 | from collections.abc import Iterable |
6 | 8 | from contextlib import suppress |
7 | 9 | from pathlib import Path |
|
10 | 12 | import pytest |
11 | 13 | from faker import Faker |
12 | 14 | from servicelib.logging_utils import ( |
| 15 | + _DEFAULT_FORMATTING, |
| 16 | + CustomFormatter, |
13 | 17 | LogExtra, |
14 | 18 | LogLevelInt, |
15 | 19 | LogMessageStr, |
@@ -573,3 +577,175 @@ def _raise_test_exception(): |
573 | 577 |
|
574 | 578 | # Check that the message was logged and cleanup happened |
575 | 579 | assert "Message before exception" in caplog.text |
| 580 | + |
| 581 | + |
| 582 | +def _create_grok_regex_pattern() -> str: |
| 583 | + """Convert Grok pattern to regex for testing.""" |
| 584 | + # The Grok pattern from the comment: |
| 585 | + # log_level=%{WORD:log_level} \| log_timestamp=%{TIMESTAMP_ISO8601:log_timestamp} \| log_source=%{NOTSPACE:log_source} \| log_uid=%{NOTSPACE:log_uid} \| log_oec=%{NOTSPACE:log_oec} \| log_trace_id=%{NOTSPACE:log_trace_id} \| log_span_id=%{NOTSPACE:log_span_id} \| log_msg=%{GREEDYDATA:log_msg} |
| 586 | + |
| 587 | + grok_to_regex = { |
| 588 | + r"%{WORD:log_level}": r"(?P<log_level>\w+)", |
| 589 | + r"%{TIMESTAMP_ISO8601:log_timestamp}": r"(?P<log_timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})", |
| 590 | + r"%{NOTSPACE:log_source}": r"(?P<log_source>\S+)", |
| 591 | + r"%{NOTSPACE:log_uid}": r"(?P<log_uid>\S+)", |
| 592 | + r"%{NOTSPACE:log_oec}": r"(?P<log_oec>\S+)", |
| 593 | + r"%{NOTSPACE:log_trace_id}": r"(?P<log_trace_id>\S+)", |
| 594 | + r"%{NOTSPACE:log_span_id}": r"(?P<log_span_id>\S+)", |
| 595 | + r"%{GREEDYDATA:log_msg}": r"(?P<log_msg>.*)", |
| 596 | + } |
| 597 | + |
| 598 | + grok_pattern = r"log_level=%{WORD:log_level} \| log_timestamp=%{TIMESTAMP_ISO8601:log_timestamp} \| log_source=%{NOTSPACE:log_source} \| log_uid=%{NOTSPACE:log_uid} \| log_oec=%{NOTSPACE:log_oec} \| log_trace_id=%{NOTSPACE:log_trace_id} \| log_span_id=%{NOTSPACE:log_span_id} \| log_msg=%{GREEDYDATA:log_msg}" |
| 599 | + |
| 600 | + # Convert to regex |
| 601 | + regex_pattern = grok_pattern |
| 602 | + for grok, regex in grok_to_regex.items(): |
| 603 | + regex_pattern = regex_pattern.replace(grok, regex) |
| 604 | + |
| 605 | + return regex_pattern |
| 606 | + |
| 607 | + |
| 608 | +def _create_test_log_record( |
| 609 | + name: str, |
| 610 | + level: int, |
| 611 | + func_name: str, |
| 612 | + lineno: int, |
| 613 | + message: str, |
| 614 | + *, |
| 615 | + user_id: int | str | None = None, |
| 616 | + error_code: str | None = None, |
| 617 | + trace_id: str | None = None, |
| 618 | + span_id: str | None = None, |
| 619 | +) -> logging.LogRecord: |
| 620 | + """Create a test LogRecord with optional extra fields.""" |
| 621 | + from servicelib.logging_utils import get_log_record_extra |
| 622 | + |
| 623 | + record = logging.LogRecord( |
| 624 | + name=name, |
| 625 | + level=level, |
| 626 | + pathname="/path/to/file.py", |
| 627 | + lineno=lineno, |
| 628 | + msg=message, |
| 629 | + args=(), |
| 630 | + exc_info=None, |
| 631 | + func=func_name, |
| 632 | + ) |
| 633 | + |
| 634 | + # Add extra fields if provided |
| 635 | + extra = get_log_record_extra(user_id=user_id, error_code=error_code) |
| 636 | + if extra: |
| 637 | + for key, value in extra.items(): |
| 638 | + setattr(record, key, value) |
| 639 | + |
| 640 | + # Add OpenTelemetry trace ID |
| 641 | + record.otelTraceID = trace_id # type: ignore[attr-defined] |
| 642 | + record.otelSpanID = span_id # type: ignore[attr-defined] |
| 643 | + |
| 644 | + return record |
| 645 | + |
| 646 | + |
| 647 | +def test_grok_pattern_parsing(caplog: pytest.LogCaptureFixture) -> None: |
| 648 | + """ |
| 649 | + Test that the Graylog Grok pattern correctly parses logs formatted with _DEFAULT_FORMATTING. |
| 650 | +
|
| 651 | + This test validates that the Grok pattern defined in the comment can correctly |
| 652 | + parse logs formatted with _DEFAULT_FORMATTING. |
| 653 | +
|
| 654 | + WARNING: If log formatting changes, the Grok pattern in Graylog must be updated accordingly. |
| 655 | + """ |
| 656 | + |
| 657 | + # Create a custom handler with the default formatter |
| 658 | + log_stream = io.StringIO() |
| 659 | + handler = logging.StreamHandler(log_stream) |
| 660 | + formatter = CustomFormatter(_DEFAULT_FORMATTING, log_format_local_dev_enabled=False) |
| 661 | + handler.setFormatter(formatter) |
| 662 | + |
| 663 | + # Create test log record with all fields populated |
| 664 | + test_message = ( |
| 665 | + "This is a test log message with special chars: []{} and new line\nembedded" |
| 666 | + ) |
| 667 | + record = _create_test_log_record( |
| 668 | + name="test.module.submodule", |
| 669 | + level=logging.INFO, |
| 670 | + func_name="test_function", |
| 671 | + lineno=42, |
| 672 | + message=test_message, |
| 673 | + user_id=12345, |
| 674 | + error_code="OEC001", |
| 675 | + trace_id="1234567890abcdef1234567890abcdef", |
| 676 | + span_id="987654321", |
| 677 | + ) |
| 678 | + |
| 679 | + # Format the record |
| 680 | + formatted_log = formatter.format(record) |
| 681 | + |
| 682 | + # Test that the formatted log matches the Grok pattern |
| 683 | + regex_pattern = _create_grok_regex_pattern() |
| 684 | + match = re.match(regex_pattern, formatted_log) |
| 685 | + |
| 686 | + assert ( |
| 687 | + match is not None |
| 688 | + ), f"Grok pattern did not match formatted log. Log: {formatted_log!r}" |
| 689 | + |
| 690 | + # Verify extracted fields match expected values |
| 691 | + groups = match.groupdict() |
| 692 | + |
| 693 | + assert groups["log_level"] == "INFO" |
| 694 | + assert groups["log_source"] == "test.module.submodule:test_function(42)" |
| 695 | + assert groups["log_uid"] == "12345" |
| 696 | + assert groups["log_oec"] == "OEC001" |
| 697 | + assert groups["log_trace_id"] == "1234567890abcdef1234567890abcdef" |
| 698 | + assert groups["log_span_id"] == "987654321" |
| 699 | + |
| 700 | + # Verify the message is correctly escaped (newlines become \\n) |
| 701 | + expected_message = test_message.replace("\n", "\\n") |
| 702 | + assert groups["log_msg"] == expected_message |
| 703 | + |
| 704 | + # Verify timestamp format is ISO8601-like (as expected by Python logging) |
| 705 | + timestamp_pattern = r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}" |
| 706 | + assert re.match(timestamp_pattern, groups["log_timestamp"]) |
| 707 | + |
| 708 | + |
| 709 | +def test_grok_pattern_parsing_with_none_values( |
| 710 | + caplog: pytest.LogCaptureFixture, |
| 711 | +) -> None: |
| 712 | + """ |
| 713 | + Test Grok pattern parsing when optional fields are None. |
| 714 | +
|
| 715 | + WARNING: If log formatting changes, the Grok pattern in Graylog must be updated accordingly. |
| 716 | + """ |
| 717 | + |
| 718 | + # Create a custom handler with the default formatter |
| 719 | + handler = logging.StreamHandler(io.StringIO()) |
| 720 | + formatter = CustomFormatter(_DEFAULT_FORMATTING, log_format_local_dev_enabled=False) |
| 721 | + handler.setFormatter(formatter) |
| 722 | + |
| 723 | + # Create test log record with None values for optional fields |
| 724 | + record = _create_test_log_record( |
| 725 | + name="test.module", |
| 726 | + level=logging.ERROR, |
| 727 | + func_name="error_function", |
| 728 | + lineno=100, |
| 729 | + message="Error message", |
| 730 | + user_id=None, |
| 731 | + error_code=None, |
| 732 | + trace_id=None, |
| 733 | + span_id=None, |
| 734 | + ) |
| 735 | + |
| 736 | + formatted_log = formatter.format(record) |
| 737 | + regex_pattern = _create_grok_regex_pattern() |
| 738 | + match = re.match(regex_pattern, formatted_log) |
| 739 | + |
| 740 | + assert ( |
| 741 | + match is not None |
| 742 | + ), f"Grok pattern did not match log with None values. Log: {formatted_log!r}" |
| 743 | + |
| 744 | + groups = match.groupdict() |
| 745 | + assert groups["log_level"] == "ERROR" |
| 746 | + assert groups["log_source"] == "test.module:error_function(100)" |
| 747 | + assert groups["log_uid"] == "None" |
| 748 | + assert groups["log_oec"] == "None" |
| 749 | + assert groups["log_trace_id"] == "None" |
| 750 | + assert groups["log_span_id"] == "None" |
| 751 | + assert groups["log_msg"] == "Error message" |
0 commit comments