Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/instana/options.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# (c) Copyright IBM Corp. 2021
# (c) Copyright IBM Corp. 2021, 2025
# (c) Copyright Instana Inc. 2016

"""
Expand Down Expand Up @@ -50,6 +50,10 @@ def __init__(self, **kwds: Dict[str, Any]) -> None:
# enabled_spans lists all categories and types that should be enabled, preceding disabled_spans
self.enabled_spans = []

# Stack trace configuration - global defaults
self.stack_trace_level = "all" # Options: "all", "error", "none"
self.stack_trace_length = 30 # Default: 30, recommended range: 10-40

self.set_trace_configurations()

# Defaults
Expand Down Expand Up @@ -122,6 +126,33 @@ def set_trace_configurations(self) -> None:
)

self.set_disable_trace_configurations()
self.set_stack_trace_configurations()

def set_stack_trace_configurations(self) -> None:
# Stack trace level configuration
if "INSTANA_STACK_TRACE" in os.environ:
level = os.environ["INSTANA_STACK_TRACE"].lower()
if level in ["all", "error", "none"]:
self.stack_trace_level = level
else:
logger.warning(
f"Invalid INSTANA_STACK_TRACE value: {level}. Must be 'all', 'error', or 'none'. Using default 'all'"
)

# Stack trace length configuration
if "INSTANA_STACK_TRACE_LENGTH" in os.environ:
try:
length = int(os.environ["INSTANA_STACK_TRACE_LENGTH"])
if length >= 1:
self.stack_trace_length = length
else:
logger.warning(
"INSTANA_STACK_TRACE_LENGTH must be positive. Using default 30"
)
except ValueError:
logger.warning(
"Invalid INSTANA_STACK_TRACE_LENGTH value. Must be an integer. Using default 30"
)

def set_disable_trace_configurations(self) -> None:
disabled_spans = []
Expand Down
5 changes: 4 additions & 1 deletion src/instana/span/span.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# (c) Copyright IBM Corp. 2021
# (c) Copyright IBM Corp. 2021, 2025
# (c) Copyright Instana Inc. 2017

"""
Expand Down Expand Up @@ -36,6 +36,7 @@
from instana.recorder import StanRecorder
from instana.span.kind import HTTP_SPANS
from instana.span.readable_span import Event, ReadableSpan
from instana.span.stack_trace import add_stack_trace_if_needed
from instana.span_context import SpanContext


Expand Down Expand Up @@ -197,6 +198,8 @@ def end(self, end_time: Optional[int] = None) -> None:
self._end_time = end_time if end_time else time_ns()
self._duration = self._end_time - self._start_time

add_stack_trace_if_needed(self)

self._span_processor.record_span(self._readable_span())

def mark_as_errored(self, attributes: types.Attributes = None) -> None:
Expand Down
156 changes: 156 additions & 0 deletions src/instana/span/stack_trace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# (c) Copyright IBM Corp. 2025

"""
Stack trace collection functionality for spans.

This module provides utilities for capturing and filtering stack traces
for EXIT spans based on configuration settings.
"""

import os
import re
import traceback
from typing import List, Optional, TYPE_CHECKING

from instana.log import logger
from instana.span.kind import EXIT_SPANS

if TYPE_CHECKING:
from instana.span.span import InstanaSpan

# Regex patterns for filtering Instana internal frames
_re_tracer_frame = re.compile(r"/instana/.*\.py$")
_re_with_stan_frame = re.compile("with_instana")


def _should_collect_stack(level: str, is_errored: bool) -> bool:
"""
Determine if stack trace should be collected based on level and error state.

Args:
level: Stack trace collection level ("all", "error", or "none")
is_errored: Whether the span has errors (ec > 0)

Returns:
True if stack trace should be collected, False otherwise
"""
if level == "all":
return True
if level == "error" and is_errored:
return True
return False


def _should_exclude_frame(frame) -> bool:
"""
Check if a frame should be excluded from the stack trace.

Frames are excluded if they are part of Instana's internal code,
unless INSTANA_DEBUG is set.

Args:
frame: A frame from traceback.extract_stack()

Returns:
True if frame should be excluded, False otherwise
"""
if "INSTANA_DEBUG" in os.environ:
return False
if _re_tracer_frame.search(frame[0]):
return True
if _re_with_stan_frame.search(frame[2]):
return True
return False


def _apply_stack_limit(
sanitized_stack: List[dict], limit: int, use_full_stack: bool
) -> List[dict]:
"""
Apply frame limit to the sanitized stack.

Args:
sanitized_stack: List of stack frames
limit: Maximum number of frames to include
use_full_stack: If True, ignore the limit

Returns:
Limited stack trace
"""
if use_full_stack or len(sanitized_stack) <= limit:
return sanitized_stack
# (limit * -1) gives us negative form of <limit> used for
# slicing from the end of the list. e.g. stack[-25:]
return sanitized_stack[(limit * -1) :]


def add_stack(
level: str, limit: int, is_errored: bool = False
) -> Optional[List[dict]]:
"""
Capture and return a stack trace based on configuration.

This function collects the current call stack, filters out Instana
internal frames, and applies the configured limit.

Args:
level: Stack trace collection level ("all", "error", or "none")
limit: Maximum number of frames to include (1-40)
is_errored: Whether the span has errors (ec > 0)

Returns:
List of stack frames in format [{"c": file, "n": line, "m": method}, ...]
or None if stack trace should not be collected
"""
try:
# Determine if we should collect stack trace
if not _should_collect_stack(level, is_errored):
return None

# For erroneous EXIT spans, MAY consider the whole stack
use_full_stack = is_errored

# Enforce hard limit of 40 frames (unless errored and using full stack)
if not use_full_stack and limit > 40:
limit = 40

sanitized_stack = []
trace_back = traceback.extract_stack()
trace_back.reverse()

for frame in trace_back:
if _should_exclude_frame(frame):
continue
sanitized_stack.append({"c": frame[0], "n": frame[1], "m": frame[2]})

# Apply limit (unless it's an errored span and we want full stack)
return _apply_stack_limit(sanitized_stack, limit, use_full_stack)

except Exception:
logger.debug("add_stack: ", exc_info=True)
return None


def add_stack_trace_if_needed(span: "InstanaSpan") -> None:
"""
Add stack trace to span based on configuration before span ends.

This function checks if the span is an EXIT span and if so, captures
a stack trace based on the configured level and limit.

Args:
span: The InstanaSpan to potentially add stack trace to
"""
if span.name in EXIT_SPANS:
# Get configuration from agent options
options = span._span_processor.agent.options

# Check if span is errored
is_errored = span.attributes.get("ec", 0) > 0

# Capture stack trace using add_stack function
span.stack = add_stack(
level=options.stack_trace_level,
limit=options.stack_trace_length,
is_errored=is_errored
)
45 changes: 0 additions & 45 deletions src/instana/tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@
# (c) Copyright Instana Inc. 2016


import os
import re
import time
import traceback
from contextlib import contextmanager
from typing import TYPE_CHECKING, Iterator, Mapping, Optional, Type, Union

Expand All @@ -30,7 +27,6 @@
from instana.propagators.text_propagator import TextPropagator
from instana.recorder import StanRecorder
from instana.sampling import InstanaSampler, Sampler
from instana.span.kind import EXIT_SPANS
from instana.span.span import InstanaSpan, get_current_span
from instana.span_context import SpanContext
from instana.util.ids import generate_id
Expand Down Expand Up @@ -138,9 +134,6 @@ def start_span(
# events: Sequence[Event] = None,
)

if name in EXIT_SPANS:
self._add_stack(span)

return span

@contextmanager
Expand Down Expand Up @@ -174,39 +167,6 @@ def start_as_current_span(
) as span:
yield span

def _add_stack(self, span: InstanaSpan, limit: Optional[int] = 30) -> None:
"""
Adds a backtrace to <span>. The default length limit for
stack traces is 30 frames. A hard limit of 40 frames is enforced.
"""
try:
sanitized_stack = []
if limit > 40:
limit = 40

trace_back = traceback.extract_stack()
trace_back.reverse()
for frame in trace_back:
# Exclude Instana frames unless we're in dev mode
if "INSTANA_DEBUG" not in os.environ:
if re_tracer_frame.search(frame[0]) is not None:
continue

if re_with_stan_frame.search(frame[2]) is not None:
continue

sanitized_stack.append({"c": frame[0], "n": frame[1], "m": frame[2]})

if len(sanitized_stack) > limit:
# (limit * -1) gives us negative form of <limit> used for
# slicing from the end of the list. e.g. stack[-30:]
span.stack = sanitized_stack[(limit * -1) :]
else:
span.stack = sanitized_stack
except Exception:
# No fail
pass

def _create_span_context(self, parent_context: SpanContext) -> SpanContext:
"""Creates a new SpanContext based on the given parent context."""

Expand Down Expand Up @@ -270,8 +230,3 @@ def extract(
return self._propagators[format].extract(carrier, disable_w3c_trace_context)

raise UnsupportedFormatException()


# Used by __add_stack
re_tracer_frame = re.compile(r"/instana/.*\.py$")
re_with_stan_frame = re.compile("with_instana")
Loading
Loading