airbyte-internal-ops 0.2.4__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {airbyte_internal_ops-0.2.4.dist-info → airbyte_internal_ops-0.3.1.dist-info}/METADATA +1 -1
- {airbyte_internal_ops-0.2.4.dist-info → airbyte_internal_ops-0.3.1.dist-info}/RECORD +18 -15
- airbyte_ops_mcp/cli/cloud.py +79 -0
- airbyte_ops_mcp/cloud_admin/api_client.py +463 -69
- airbyte_ops_mcp/connection_config_retriever/audit_logging.py +4 -8
- airbyte_ops_mcp/constants.py +3 -0
- airbyte_ops_mcp/gcp_auth.py +142 -49
- airbyte_ops_mcp/gcp_logs/__init__.py +18 -0
- airbyte_ops_mcp/gcp_logs/error_lookup.py +384 -0
- airbyte_ops_mcp/mcp/cloud_connector_versions.py +20 -1
- airbyte_ops_mcp/mcp/gcp_logs.py +92 -0
- airbyte_ops_mcp/mcp/prod_db_queries.py +180 -7
- airbyte_ops_mcp/mcp/server.py +2 -0
- airbyte_ops_mcp/prod_db_access/queries.py +82 -1
- airbyte_ops_mcp/prod_db_access/sql.py +299 -0
- airbyte_ops_mcp/regression_tests/connection_secret_retriever.py +0 -4
- {airbyte_internal_ops-0.2.4.dist-info → airbyte_internal_ops-0.3.1.dist-info}/WHEEL +0 -0
- {airbyte_internal_ops-0.2.4.dist-info → airbyte_internal_ops-0.3.1.dist-info}/entry_points.txt +0 -0
|
@@ -11,9 +11,8 @@ import logging
|
|
|
11
11
|
import subprocess
|
|
12
12
|
from typing import TYPE_CHECKING, Any, Callable
|
|
13
13
|
|
|
14
|
-
from google.cloud import logging as gcloud_logging
|
|
15
|
-
|
|
16
14
|
from airbyte_ops_mcp.constants import GCP_PROJECT_NAME
|
|
15
|
+
from airbyte_ops_mcp.gcp_auth import get_logging_client
|
|
17
16
|
|
|
18
17
|
if TYPE_CHECKING:
|
|
19
18
|
from airbyte_ops_mcp.connection_config_retriever.retrieval import (
|
|
@@ -23,21 +22,18 @@ if TYPE_CHECKING:
|
|
|
23
22
|
LOGGER = logging.getLogger(__name__)
|
|
24
23
|
|
|
25
24
|
# Lazy-initialized to avoid import-time GCP calls
|
|
26
|
-
_logging_client: gcloud_logging.Client | None = None
|
|
27
25
|
_airbyte_gcloud_logger: Any = None
|
|
28
26
|
|
|
29
27
|
|
|
30
28
|
def _get_logger() -> Any:
|
|
31
29
|
"""Get the GCP Cloud Logger, initializing lazily on first use."""
|
|
32
|
-
global
|
|
30
|
+
global _airbyte_gcloud_logger
|
|
33
31
|
|
|
34
32
|
if _airbyte_gcloud_logger is not None:
|
|
35
33
|
return _airbyte_gcloud_logger
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
_airbyte_gcloud_logger =
|
|
39
|
-
"airbyte-cloud-connection-retriever"
|
|
40
|
-
)
|
|
35
|
+
logging_client = get_logging_client(GCP_PROJECT_NAME)
|
|
36
|
+
_airbyte_gcloud_logger = logging_client.logger("airbyte-cloud-connection-retriever")
|
|
41
37
|
return _airbyte_gcloud_logger
|
|
42
38
|
|
|
43
39
|
|
airbyte_ops_mcp/constants.py
CHANGED
|
@@ -10,6 +10,9 @@ from airbyte.exceptions import PyAirbyteInputError
|
|
|
10
10
|
MCP_SERVER_NAME = "airbyte-internal-ops"
|
|
11
11
|
"""The name of the MCP server."""
|
|
12
12
|
|
|
13
|
+
USER_AGENT = "Airbyte-Internal-Ops Python client"
|
|
14
|
+
"""User-Agent string for HTTP requests to Airbyte Cloud APIs."""
|
|
15
|
+
|
|
13
16
|
# Environment variable names for internal admin authentication
|
|
14
17
|
ENV_AIRBYTE_INTERNAL_ADMIN_FLAG = "AIRBYTE_INTERNAL_ADMIN_FLAG"
|
|
15
18
|
ENV_AIRBYTE_INTERNAL_ADMIN_USER = "AIRBYTE_INTERNAL_ADMIN_USER"
|
airbyte_ops_mcp/gcp_auth.py
CHANGED
|
@@ -6,94 +6,187 @@ the airbyte-ops-mcp codebase. It supports both standard Application Default
|
|
|
6
6
|
Credentials (ADC) and the GCP_PROD_DB_ACCESS_CREDENTIALS environment variable
|
|
7
7
|
used internally at Airbyte.
|
|
8
8
|
|
|
9
|
+
The preferred approach is to pass credentials directly to GCP client constructors
|
|
10
|
+
rather than relying on file-based ADC discovery. This module provides helpers
|
|
11
|
+
that construct credentials from JSON content in environment variables.
|
|
12
|
+
|
|
9
13
|
Usage:
|
|
10
|
-
from airbyte_ops_mcp.gcp_auth import get_secret_manager_client
|
|
14
|
+
from airbyte_ops_mcp.gcp_auth import get_gcp_credentials, get_secret_manager_client
|
|
15
|
+
|
|
16
|
+
# Get credentials object to pass to any GCP client
|
|
17
|
+
credentials = get_gcp_credentials()
|
|
18
|
+
client = logging.Client(project="my-project", credentials=credentials)
|
|
11
19
|
|
|
12
|
-
#
|
|
20
|
+
# Or use the convenience helper for Secret Manager
|
|
13
21
|
client = get_secret_manager_client()
|
|
14
22
|
"""
|
|
15
23
|
|
|
16
24
|
from __future__ import annotations
|
|
17
25
|
|
|
26
|
+
import json
|
|
18
27
|
import logging
|
|
19
28
|
import os
|
|
20
|
-
import
|
|
21
|
-
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
22
31
|
|
|
32
|
+
import google.auth
|
|
33
|
+
from google.cloud import logging as gcp_logging
|
|
23
34
|
from google.cloud import secretmanager
|
|
35
|
+
from google.oauth2 import service_account
|
|
24
36
|
|
|
25
37
|
from airbyte_ops_mcp.constants import ENV_GCP_PROD_DB_ACCESS_CREDENTIALS
|
|
26
38
|
|
|
27
39
|
logger = logging.getLogger(__name__)
|
|
28
40
|
|
|
29
|
-
# Environment variable name (internal to GCP libraries)
|
|
30
|
-
ENV_GOOGLE_APPLICATION_CREDENTIALS = "GOOGLE_APPLICATION_CREDENTIALS"
|
|
31
41
|
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
def _get_identity_from_service_account_info(info: dict) -> str | None:
|
|
43
|
+
"""Extract service account identity from parsed JSON info.
|
|
44
|
+
|
|
45
|
+
Only accesses the 'client_email' key to avoid any risk of leaking
|
|
46
|
+
other credential material.
|
|
34
47
|
|
|
48
|
+
Args:
|
|
49
|
+
info: Parsed service account JSON as a dict.
|
|
35
50
|
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
Returns:
|
|
52
|
+
The client_email if present and a string, otherwise None.
|
|
53
|
+
"""
|
|
54
|
+
client_email = info.get("client_email")
|
|
55
|
+
if isinstance(client_email, str):
|
|
56
|
+
return client_email
|
|
57
|
+
return None
|
|
38
58
|
|
|
39
|
-
If GOOGLE_APPLICATION_CREDENTIALS is not set but GCP_PROD_DB_ACCESS_CREDENTIALS is,
|
|
40
|
-
write the JSON credentials to a temp file and set GOOGLE_APPLICATION_CREDENTIALS
|
|
41
|
-
to point to that file. This provides a fallback for internal employees who use
|
|
42
|
-
GCP_PROD_DB_ACCESS_CREDENTIALS as their standard credential source.
|
|
43
59
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
60
|
+
def _get_identity_from_credentials(
|
|
61
|
+
credentials: google.auth.credentials.Credentials,
|
|
62
|
+
) -> str | None:
|
|
63
|
+
"""Extract identity from a credentials object using safe attribute access.
|
|
47
64
|
|
|
48
|
-
|
|
65
|
+
Only accesses known-safe attributes that don't trigger network calls
|
|
66
|
+
or token refresh.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
credentials: A GCP credentials object.
|
|
49
70
|
|
|
50
71
|
Returns:
|
|
51
|
-
The
|
|
52
|
-
GOOGLE_APPLICATION_CREDENTIALS was already set.
|
|
72
|
+
The service account email if available, otherwise None.
|
|
53
73
|
"""
|
|
54
|
-
|
|
74
|
+
# Try service_account_email first (most common for service accounts)
|
|
75
|
+
identity = getattr(credentials, "service_account_email", None)
|
|
76
|
+
if isinstance(identity, str):
|
|
77
|
+
return identity
|
|
78
|
+
|
|
79
|
+
# Try signer_email as fallback (sometimes present on impersonated creds)
|
|
80
|
+
identity = getattr(credentials, "signer_email", None)
|
|
81
|
+
if isinstance(identity, str):
|
|
82
|
+
return identity
|
|
83
|
+
|
|
84
|
+
return None
|
|
85
|
+
|
|
55
86
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return None
|
|
87
|
+
# Default scopes for GCP services used by this module
|
|
88
|
+
DEFAULT_GCP_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
|
|
59
89
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return None
|
|
90
|
+
# Module-level cache for credentials (thread-safe)
|
|
91
|
+
_cached_credentials: google.auth.credentials.Credentials | None = None
|
|
92
|
+
_credentials_lock = threading.Lock()
|
|
64
93
|
|
|
65
|
-
# Reuse the same file path if we've already written credentials and file still exists
|
|
66
|
-
if _credentials_file_path is not None and Path(_credentials_file_path).exists():
|
|
67
|
-
os.environ[ENV_GOOGLE_APPLICATION_CREDENTIALS] = _credentials_file_path
|
|
68
|
-
return _credentials_file_path
|
|
69
94
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
creds_file = Path(tempfile.gettempdir()) / f"gcp_prod_db_creds_{os.getpid()}.json"
|
|
73
|
-
creds_file.write_text(gsm_creds)
|
|
95
|
+
def get_gcp_credentials() -> google.auth.credentials.Credentials:
|
|
96
|
+
"""Get GCP credentials, preferring direct JSON parsing over file-based ADC.
|
|
74
97
|
|
|
75
|
-
|
|
76
|
-
|
|
98
|
+
This function resolves credentials in the following order:
|
|
99
|
+
1. GCP_PROD_DB_ACCESS_CREDENTIALS env var (JSON content) - parsed directly
|
|
100
|
+
2. Standard ADC discovery (workload identity, gcloud auth, GOOGLE_APPLICATION_CREDENTIALS)
|
|
77
101
|
|
|
78
|
-
|
|
79
|
-
|
|
102
|
+
The credentials are cached after first resolution for efficiency.
|
|
103
|
+
Uses the cloud-platform scope which provides access to all GCP services.
|
|
80
104
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
f"{ENV_GOOGLE_APPLICATION_CREDENTIALS}"
|
|
84
|
-
)
|
|
105
|
+
Returns:
|
|
106
|
+
A Credentials object that can be passed to any GCP client constructor.
|
|
85
107
|
|
|
86
|
-
|
|
108
|
+
Raises:
|
|
109
|
+
google.auth.exceptions.DefaultCredentialsError: If no credentials can be found.
|
|
110
|
+
"""
|
|
111
|
+
global _cached_credentials
|
|
112
|
+
|
|
113
|
+
# Return cached credentials if available (fast path without lock)
|
|
114
|
+
if _cached_credentials is not None:
|
|
115
|
+
return _cached_credentials
|
|
116
|
+
|
|
117
|
+
# Acquire lock for thread-safe credential initialization
|
|
118
|
+
with _credentials_lock:
|
|
119
|
+
# Double-check after acquiring lock (another thread may have initialized)
|
|
120
|
+
if _cached_credentials is not None:
|
|
121
|
+
return _cached_credentials
|
|
122
|
+
|
|
123
|
+
# Try GCP_PROD_DB_ACCESS_CREDENTIALS first (JSON content in env var)
|
|
124
|
+
creds_json = os.getenv(ENV_GCP_PROD_DB_ACCESS_CREDENTIALS)
|
|
125
|
+
if creds_json:
|
|
126
|
+
try:
|
|
127
|
+
creds_dict = json.loads(creds_json)
|
|
128
|
+
credentials = service_account.Credentials.from_service_account_info(
|
|
129
|
+
creds_dict,
|
|
130
|
+
scopes=DEFAULT_GCP_SCOPES,
|
|
131
|
+
)
|
|
132
|
+
# Extract identity safely (only after successful credential creation)
|
|
133
|
+
identity = _get_identity_from_service_account_info(creds_dict)
|
|
134
|
+
identity_str = f" (identity: {identity})" if identity else ""
|
|
135
|
+
print(
|
|
136
|
+
f"GCP credentials loaded from {ENV_GCP_PROD_DB_ACCESS_CREDENTIALS}{identity_str}",
|
|
137
|
+
file=sys.stderr,
|
|
138
|
+
)
|
|
139
|
+
logger.debug(
|
|
140
|
+
f"Loaded GCP credentials from {ENV_GCP_PROD_DB_ACCESS_CREDENTIALS} env var"
|
|
141
|
+
)
|
|
142
|
+
_cached_credentials = credentials
|
|
143
|
+
return credentials
|
|
144
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
145
|
+
# Log only exception type to avoid any risk of leaking credential content
|
|
146
|
+
logger.warning(
|
|
147
|
+
f"Failed to parse {ENV_GCP_PROD_DB_ACCESS_CREDENTIALS}: "
|
|
148
|
+
f"{type(e).__name__}. Falling back to ADC discovery."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Fall back to standard ADC discovery
|
|
152
|
+
credentials, project = google.auth.default(scopes=DEFAULT_GCP_SCOPES)
|
|
153
|
+
# Extract identity safely from ADC credentials
|
|
154
|
+
identity = _get_identity_from_credentials(credentials)
|
|
155
|
+
identity_str = f" (identity: {identity})" if identity else ""
|
|
156
|
+
project_str = f" (project: {project})" if project else ""
|
|
157
|
+
print(
|
|
158
|
+
f"GCP credentials loaded via ADC{project_str}{identity_str}",
|
|
159
|
+
file=sys.stderr,
|
|
160
|
+
)
|
|
161
|
+
logger.debug(f"Loaded GCP credentials via ADC discovery (project: {project})")
|
|
162
|
+
_cached_credentials = credentials
|
|
163
|
+
return credentials
|
|
87
164
|
|
|
88
165
|
|
|
89
166
|
def get_secret_manager_client() -> secretmanager.SecretManagerServiceClient:
|
|
90
167
|
"""Get a Secret Manager client with proper credential handling.
|
|
91
168
|
|
|
92
|
-
This function
|
|
93
|
-
|
|
169
|
+
This function uses get_gcp_credentials() to resolve credentials and passes
|
|
170
|
+
them directly to the client constructor.
|
|
94
171
|
|
|
95
172
|
Returns:
|
|
96
173
|
A configured SecretManagerServiceClient instance.
|
|
97
174
|
"""
|
|
98
|
-
|
|
99
|
-
return secretmanager.SecretManagerServiceClient()
|
|
175
|
+
credentials = get_gcp_credentials()
|
|
176
|
+
return secretmanager.SecretManagerServiceClient(credentials=credentials)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_logging_client(project: str) -> gcp_logging.Client:
|
|
180
|
+
"""Get a Cloud Logging client with proper credential handling.
|
|
181
|
+
|
|
182
|
+
This function uses get_gcp_credentials() to resolve credentials and passes
|
|
183
|
+
them directly to the client constructor.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
project: The GCP project ID to use for logging operations.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
A configured Cloud Logging Client instance.
|
|
190
|
+
"""
|
|
191
|
+
credentials = get_gcp_credentials()
|
|
192
|
+
return gcp_logging.Client(project=project, credentials=credentials)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
2
|
+
"""GCP Cloud Logging utilities for fetching error details by error ID."""
|
|
3
|
+
|
|
4
|
+
from airbyte_ops_mcp.gcp_logs.error_lookup import (
|
|
5
|
+
GCPLogEntry,
|
|
6
|
+
GCPLogPayload,
|
|
7
|
+
GCPLogSearchResult,
|
|
8
|
+
GCPSeverity,
|
|
9
|
+
fetch_error_logs,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"GCPLogEntry",
|
|
14
|
+
"GCPLogPayload",
|
|
15
|
+
"GCPLogSearchResult",
|
|
16
|
+
"GCPSeverity",
|
|
17
|
+
"fetch_error_logs",
|
|
18
|
+
]
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
2
|
+
"""Fetch full stack traces from Google Cloud Logs by error ID.
|
|
3
|
+
|
|
4
|
+
This module provides functionality to look up error details from GCP Cloud Logging
|
|
5
|
+
using an error ID (UUID). This is useful for debugging API errors that return
|
|
6
|
+
only an error ID in the response.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from airbyte_ops_mcp.gcp_logs import fetch_error_logs
|
|
10
|
+
|
|
11
|
+
result = fetch_error_logs(
|
|
12
|
+
error_id="3173452e-8f22-4286-a1ec-b0f16c1e078a",
|
|
13
|
+
project="prod-ab-cloud-proj",
|
|
14
|
+
lookback_days=7,
|
|
15
|
+
)
|
|
16
|
+
for entry in result.entries:
|
|
17
|
+
print(entry.message)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from datetime import UTC, datetime, timedelta
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from google.cloud import logging as gcp_logging
|
|
28
|
+
from google.cloud.logging_v2 import entries
|
|
29
|
+
from pydantic import BaseModel, Field
|
|
30
|
+
|
|
31
|
+
from airbyte_ops_mcp.gcp_auth import get_logging_client
|
|
32
|
+
|
|
33
|
+
# Default GCP project for Airbyte Cloud
|
|
34
|
+
DEFAULT_GCP_PROJECT = "prod-ab-cloud-proj"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GCPSeverity(StrEnum):
|
|
38
|
+
"""Valid GCP Cloud Logging severity levels."""
|
|
39
|
+
|
|
40
|
+
DEBUG = "DEBUG"
|
|
41
|
+
INFO = "INFO"
|
|
42
|
+
NOTICE = "NOTICE"
|
|
43
|
+
WARNING = "WARNING"
|
|
44
|
+
ERROR = "ERROR"
|
|
45
|
+
CRITICAL = "CRITICAL"
|
|
46
|
+
ALERT = "ALERT"
|
|
47
|
+
EMERGENCY = "EMERGENCY"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GCPLogResourceLabels(BaseModel):
|
|
51
|
+
"""Resource labels from a GCP log entry."""
|
|
52
|
+
|
|
53
|
+
pod_name: str | None = Field(default=None, description="Kubernetes pod name")
|
|
54
|
+
container_name: str | None = Field(
|
|
55
|
+
default=None, description="Container name within the pod"
|
|
56
|
+
)
|
|
57
|
+
namespace_name: str | None = Field(default=None, description="Kubernetes namespace")
|
|
58
|
+
cluster_name: str | None = Field(default=None, description="GKE cluster name")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GCPLogResource(BaseModel):
|
|
62
|
+
"""Resource information from a GCP log entry."""
|
|
63
|
+
|
|
64
|
+
type: str | None = Field(default=None, description="Resource type")
|
|
65
|
+
labels: GCPLogResourceLabels = Field(
|
|
66
|
+
default_factory=GCPLogResourceLabels, description="Resource labels"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GCPLogSourceLocation(BaseModel):
|
|
71
|
+
"""Source location information from a GCP log entry."""
|
|
72
|
+
|
|
73
|
+
file: str | None = Field(default=None, description="Source file path")
|
|
74
|
+
line: int | None = Field(default=None, description="Line number")
|
|
75
|
+
function: str | None = Field(default=None, description="Function name")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class GCPLogEntry(BaseModel):
|
|
79
|
+
"""A single log entry from GCP Cloud Logging."""
|
|
80
|
+
|
|
81
|
+
timestamp: datetime | None = Field(
|
|
82
|
+
default=None, description="When the log entry was created"
|
|
83
|
+
)
|
|
84
|
+
severity: str | None = Field(
|
|
85
|
+
default=None, description="Log severity (DEBUG, INFO, WARNING, ERROR, etc.)"
|
|
86
|
+
)
|
|
87
|
+
log_name: str | None = Field(default=None, description="Full log name path")
|
|
88
|
+
insert_id: str | None = Field(
|
|
89
|
+
default=None, description="Unique identifier for the log entry"
|
|
90
|
+
)
|
|
91
|
+
trace: str | None = Field(
|
|
92
|
+
default=None, description="Trace ID for distributed tracing"
|
|
93
|
+
)
|
|
94
|
+
span_id: str | None = Field(default=None, description="Span ID within the trace")
|
|
95
|
+
payload: Any = Field(default=None, description="Log entry payload (text or struct)")
|
|
96
|
+
payload_type: str | None = Field(
|
|
97
|
+
default=None, description="Type of payload (text, struct, protobuf)"
|
|
98
|
+
)
|
|
99
|
+
resource: GCPLogResource = Field(
|
|
100
|
+
default_factory=GCPLogResource, description="Resource information"
|
|
101
|
+
)
|
|
102
|
+
source_location: GCPLogSourceLocation | None = Field(
|
|
103
|
+
default=None, description="Source code location"
|
|
104
|
+
)
|
|
105
|
+
labels: dict[str, str] = Field(
|
|
106
|
+
default_factory=dict, description="User-defined labels"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class GCPLogPayload(BaseModel):
|
|
111
|
+
"""Extracted and combined payload from grouped log entries."""
|
|
112
|
+
|
|
113
|
+
timestamp: datetime | None = Field(
|
|
114
|
+
default=None, description="Timestamp of the first entry in the group"
|
|
115
|
+
)
|
|
116
|
+
severity: str | None = Field(default=None, description="Severity of the log group")
|
|
117
|
+
resource: GCPLogResource = Field(
|
|
118
|
+
default_factory=GCPLogResource, description="Resource information"
|
|
119
|
+
)
|
|
120
|
+
num_log_lines: int = Field(
|
|
121
|
+
default=0, description="Number of log lines combined into this payload"
|
|
122
|
+
)
|
|
123
|
+
message: str = Field(default="", description="Combined message from all log lines")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class GCPLogSearchResult(BaseModel):
|
|
127
|
+
"""Result of searching GCP Cloud Logging for an error ID."""
|
|
128
|
+
|
|
129
|
+
error_id: str = Field(description="The error ID that was searched for")
|
|
130
|
+
project: str = Field(description="GCP project that was searched")
|
|
131
|
+
lookback_days_searched: int = Field(
|
|
132
|
+
description="Number of lookback days that were searched"
|
|
133
|
+
)
|
|
134
|
+
total_entries_found: int = Field(
|
|
135
|
+
description="Total number of log entries found (including related entries)"
|
|
136
|
+
)
|
|
137
|
+
entries: list[GCPLogEntry] = Field(
|
|
138
|
+
default_factory=list, description="Raw log entries found"
|
|
139
|
+
)
|
|
140
|
+
payloads: list[GCPLogPayload] = Field(
|
|
141
|
+
default_factory=list,
|
|
142
|
+
description="Extracted and grouped payloads (reconstructed stack traces)",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _build_filter(
|
|
147
|
+
error_id: str,
|
|
148
|
+
lookback_days: int,
|
|
149
|
+
min_severity_filter: GCPSeverity | None,
|
|
150
|
+
) -> str:
|
|
151
|
+
"""Build the Cloud Logging filter query."""
|
|
152
|
+
filter_parts = [f'"{error_id}"']
|
|
153
|
+
|
|
154
|
+
start_time = datetime.now(UTC) - timedelta(days=lookback_days)
|
|
155
|
+
filter_parts.append(f'timestamp >= "{start_time.isoformat()}"')
|
|
156
|
+
|
|
157
|
+
if min_severity_filter:
|
|
158
|
+
filter_parts.append(f"severity>={min_severity_filter}")
|
|
159
|
+
|
|
160
|
+
return " AND ".join(filter_parts)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _entry_to_model(
|
|
164
|
+
entry: entries.StructEntry | entries.TextEntry | entries.ProtobufEntry,
|
|
165
|
+
) -> GCPLogEntry:
|
|
166
|
+
"""Convert a GCP log entry to a Pydantic model."""
|
|
167
|
+
resource_labels = {}
|
|
168
|
+
if entry.resource and entry.resource.labels:
|
|
169
|
+
resource_labels = dict(entry.resource.labels)
|
|
170
|
+
|
|
171
|
+
resource = GCPLogResource(
|
|
172
|
+
type=entry.resource.type if entry.resource else None,
|
|
173
|
+
labels=GCPLogResourceLabels(
|
|
174
|
+
pod_name=resource_labels.get("pod_name"),
|
|
175
|
+
container_name=resource_labels.get("container_name"),
|
|
176
|
+
namespace_name=resource_labels.get("namespace_name"),
|
|
177
|
+
cluster_name=resource_labels.get("cluster_name"),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
source_location = None
|
|
182
|
+
if entry.source_location:
|
|
183
|
+
source_location = GCPLogSourceLocation(
|
|
184
|
+
file=entry.source_location.get("file"),
|
|
185
|
+
line=entry.source_location.get("line"),
|
|
186
|
+
function=entry.source_location.get("function"),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
payload: Any = None
|
|
190
|
+
payload_type = "unknown"
|
|
191
|
+
if isinstance(entry, entries.StructEntry):
|
|
192
|
+
payload = entry.payload
|
|
193
|
+
payload_type = "struct"
|
|
194
|
+
elif isinstance(entry, entries.TextEntry):
|
|
195
|
+
payload = entry.payload
|
|
196
|
+
payload_type = "text"
|
|
197
|
+
elif isinstance(entry, entries.ProtobufEntry):
|
|
198
|
+
payload = str(entry.payload)
|
|
199
|
+
payload_type = "protobuf"
|
|
200
|
+
|
|
201
|
+
return GCPLogEntry(
|
|
202
|
+
timestamp=entry.timestamp,
|
|
203
|
+
severity=entry.severity,
|
|
204
|
+
log_name=entry.log_name,
|
|
205
|
+
insert_id=entry.insert_id,
|
|
206
|
+
trace=entry.trace,
|
|
207
|
+
span_id=entry.span_id,
|
|
208
|
+
payload=payload,
|
|
209
|
+
payload_type=payload_type,
|
|
210
|
+
resource=resource,
|
|
211
|
+
source_location=source_location,
|
|
212
|
+
labels=dict(entry.labels) if entry.labels else {},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _group_entries_by_occurrence(
|
|
217
|
+
log_entries: list[GCPLogEntry],
|
|
218
|
+
) -> list[list[GCPLogEntry]]:
|
|
219
|
+
"""Group log entries by occurrence (timestamp clusters within 1 second)."""
|
|
220
|
+
if not log_entries:
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
sorted_entries = sorted(
|
|
224
|
+
log_entries, key=lambda x: x.timestamp or datetime.min.replace(tzinfo=UTC)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
groups: list[list[GCPLogEntry]] = []
|
|
228
|
+
current_group = [sorted_entries[0]]
|
|
229
|
+
current_timestamp = sorted_entries[0].timestamp or datetime.min.replace(tzinfo=UTC)
|
|
230
|
+
|
|
231
|
+
for entry in sorted_entries[1:]:
|
|
232
|
+
entry_timestamp = entry.timestamp or datetime.min.replace(tzinfo=UTC)
|
|
233
|
+
time_diff = abs((entry_timestamp - current_timestamp).total_seconds())
|
|
234
|
+
|
|
235
|
+
current_pod = current_group[0].resource.labels.pod_name
|
|
236
|
+
entry_pod = entry.resource.labels.pod_name
|
|
237
|
+
|
|
238
|
+
if time_diff <= 1 and entry_pod == current_pod:
|
|
239
|
+
current_group.append(entry)
|
|
240
|
+
else:
|
|
241
|
+
groups.append(current_group)
|
|
242
|
+
current_group = [entry]
|
|
243
|
+
current_timestamp = entry_timestamp
|
|
244
|
+
|
|
245
|
+
if current_group:
|
|
246
|
+
groups.append(current_group)
|
|
247
|
+
|
|
248
|
+
return groups
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _extract_payloads(log_entries: list[GCPLogEntry]) -> list[GCPLogPayload]:
|
|
252
|
+
"""Extract and group payloads by occurrence."""
|
|
253
|
+
if not log_entries:
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
grouped = _group_entries_by_occurrence(log_entries)
|
|
257
|
+
|
|
258
|
+
results = []
|
|
259
|
+
for group in grouped:
|
|
260
|
+
payloads = []
|
|
261
|
+
for entry in group:
|
|
262
|
+
if entry.payload:
|
|
263
|
+
payload_text = str(entry.payload)
|
|
264
|
+
payload_text = re.sub(r"\x1b\[[0-9;]*m", "", payload_text)
|
|
265
|
+
payloads.append(payload_text)
|
|
266
|
+
|
|
267
|
+
combined_message = "\n".join(payloads)
|
|
268
|
+
|
|
269
|
+
first_entry = group[0]
|
|
270
|
+
result = GCPLogPayload(
|
|
271
|
+
timestamp=first_entry.timestamp,
|
|
272
|
+
severity=first_entry.severity,
|
|
273
|
+
resource=first_entry.resource,
|
|
274
|
+
num_log_lines=len(group),
|
|
275
|
+
message=combined_message,
|
|
276
|
+
)
|
|
277
|
+
results.append(result)
|
|
278
|
+
|
|
279
|
+
return results
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def fetch_error_logs(
|
|
283
|
+
error_id: str,
|
|
284
|
+
project: str = DEFAULT_GCP_PROJECT,
|
|
285
|
+
lookback_days: int = 7,
|
|
286
|
+
min_severity_filter: GCPSeverity | None = None,
|
|
287
|
+
include_log_envelope_seconds: float = 1.0,
|
|
288
|
+
max_log_entries: int | None = None,
|
|
289
|
+
) -> GCPLogSearchResult:
|
|
290
|
+
"""Fetch logs from Google Cloud Logging by error ID.
|
|
291
|
+
|
|
292
|
+
This function searches GCP Cloud Logging for log entries containing the
|
|
293
|
+
specified error ID, then fetches related log entries (multi-line stack traces)
|
|
294
|
+
from the same timestamp and resource.
|
|
295
|
+
"""
|
|
296
|
+
client = get_logging_client(project)
|
|
297
|
+
|
|
298
|
+
filter_str = _build_filter(error_id, lookback_days, min_severity_filter)
|
|
299
|
+
|
|
300
|
+
entries_iterator = client.list_entries(
|
|
301
|
+
filter_=filter_str,
|
|
302
|
+
order_by=gcp_logging.DESCENDING,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
initial_matches = list(entries_iterator)
|
|
306
|
+
|
|
307
|
+
if not initial_matches:
|
|
308
|
+
return GCPLogSearchResult(
|
|
309
|
+
error_id=error_id,
|
|
310
|
+
project=project,
|
|
311
|
+
lookback_days_searched=lookback_days,
|
|
312
|
+
total_entries_found=0,
|
|
313
|
+
entries=[],
|
|
314
|
+
payloads=[],
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
all_results: list[GCPLogEntry] = []
|
|
318
|
+
seen_insert_ids: set[str] = set()
|
|
319
|
+
|
|
320
|
+
for match in initial_matches:
|
|
321
|
+
timestamp = match.timestamp
|
|
322
|
+
resource_type_val = match.resource.type if match.resource else None
|
|
323
|
+
resource_labels = (
|
|
324
|
+
dict(match.resource.labels)
|
|
325
|
+
if match.resource and match.resource.labels
|
|
326
|
+
else {}
|
|
327
|
+
)
|
|
328
|
+
log_name = match.log_name
|
|
329
|
+
|
|
330
|
+
start_time = timestamp - timedelta(seconds=include_log_envelope_seconds)
|
|
331
|
+
end_time = timestamp + timedelta(seconds=include_log_envelope_seconds)
|
|
332
|
+
|
|
333
|
+
related_filter_parts = [
|
|
334
|
+
f'timestamp >= "{start_time.isoformat()}"',
|
|
335
|
+
f'timestamp <= "{end_time.isoformat()}"',
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
if log_name:
|
|
339
|
+
related_filter_parts.append(f'logName="{log_name}"')
|
|
340
|
+
|
|
341
|
+
if resource_type_val:
|
|
342
|
+
related_filter_parts.append(f'resource.type="{resource_type_val}"')
|
|
343
|
+
|
|
344
|
+
if "pod_name" in resource_labels:
|
|
345
|
+
related_filter_parts.append(
|
|
346
|
+
f'resource.labels.pod_name="{resource_labels["pod_name"]}"'
|
|
347
|
+
)
|
|
348
|
+
if "container_name" in resource_labels:
|
|
349
|
+
related_filter_parts.append(
|
|
350
|
+
f'resource.labels.container_name="{resource_labels["container_name"]}"'
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Note: resource_type_val is extracted from the matched entry, and
|
|
354
|
+
# min_severity_filter is already applied in the initial search filter
|
|
355
|
+
|
|
356
|
+
related_filter = " AND ".join(related_filter_parts)
|
|
357
|
+
|
|
358
|
+
related_entries = client.list_entries(
|
|
359
|
+
filter_=related_filter,
|
|
360
|
+
order_by=gcp_logging.ASCENDING,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
for entry in related_entries:
|
|
364
|
+
if entry.insert_id and entry.insert_id not in seen_insert_ids:
|
|
365
|
+
seen_insert_ids.add(entry.insert_id)
|
|
366
|
+
all_results.append(_entry_to_model(entry))
|
|
367
|
+
|
|
368
|
+
all_results.sort(
|
|
369
|
+
key=lambda x: x.timestamp or datetime.min.replace(tzinfo=UTC), reverse=True
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if max_log_entries:
|
|
373
|
+
all_results = all_results[:max_log_entries]
|
|
374
|
+
|
|
375
|
+
payloads = _extract_payloads(all_results)
|
|
376
|
+
|
|
377
|
+
return GCPLogSearchResult(
|
|
378
|
+
error_id=error_id,
|
|
379
|
+
project=project,
|
|
380
|
+
lookback_days_searched=lookback_days,
|
|
381
|
+
total_entries_found=len(all_results),
|
|
382
|
+
entries=all_results,
|
|
383
|
+
payloads=payloads,
|
|
384
|
+
)
|