Guides

Parameter Precedence in Pay-i Instrumentation

Overview

This document summarizes how parameters are handled across different instrumentation methods in Pay-i, particularly when they are nested or combined.

Pay-i's Context Management Foundation: payi_instrument()

The foundation of Pay-i's context management and automatic instrumentation is the payi_instrument() function.

  • Initialization: This function initializes the Pay-i SDK, typically called once at application startup.
  • Enables Context: It enables the context stack mechanism required for @track decorators and track_context managers to function and inherit parameters.
  • Global Defaults: It establishes the initial Global Context, setting application-wide default parameters.

For full details on its usage and configuration, see the dedicated payi_instrument() documentation.

Context Types in Pay-i Instrumentation

When initialized via payi_instrument(), the Pay-i SDK maintains a Context Stack that allows parameters to be sourced and combined from up to four different levels:

  1. Global Context

    • Created when you call payi_instrument() during initialization
    • Sets default values like the default Use Case (named after your Python module)
    • Can be configured with options like payi_instrument(config={"use_case_name": "MyApp"})
    • Initialized once at application startup
  2. @track decorator

    • Applied to functions to provide instrumentation for all API calls made within them
    • Inherits values from global context and parent decorated functions
    • Applies to the entire call tree of the decorated function, including nested function calls (unless overridden)
  3. track_context

    • A context manager that applies instrumentation to all API calls within its scope
    • Inherits from global context and any active decorated function context
    • Limited to a specific block of code
  4. custom_headers

    • Direct parameters passed to individual API calls
    • Highest precedence, overrides values from all other contexts

These different methods form a conceptual Context Stack, where inner contexts can inherit from or override parameters set by outer contexts, with custom_headers having the final say.

Note: For more details, see the Auto-Instrumentation documentation for global context, and the Custom Instrumentation documentation for @track, track_context, and custom_headers.

Parameter Behavior Patterns

When contexts are nested (e.g., track_context inside @track), parameters are determined through inheritance or override:

  • Inheritance: If a nested context doesn't specify a parameter (or uses None), it inherits the value from its parent context.
  • Override: If a nested context does specify a parameter, its value takes precedence. The exact override behavior depends on the parameter's data type and the value provided (non-empty, empty string "", or empty list []), as detailed below.

Note: The custom_headers column below summarizes how headers interact with active decorator/context scopes. This interaction is explained in detail with examples in the "Headers with Decorated Functions" section later in this document.

The following table summarizes how different value types are handled across the instrumentation methods:

Value TypeFor @track & track_contextFor custom_headers
NoneInherits from parent scopeUses decorator/context value (if scope active)
Parameter not specifiedSame as None (inherits from parent)Uses decorator/context value (if scope active)
Empty values ("", [])Overrides parent valuesSets value to empty
Non-empty valuesOverrides parent valuesOverrides decorator/context values
Non-empty listsCombines with parent valuesCombines with decorator/context values
Name-ID Pairs
ID only specifiedUses name from parent with specified IDUses name from decorator/context with specified ID
Name only specifiedNew name with new/specified IDOverrides both name and ID from decorator/context
Both specifiedBoth override parent valuesBoth override decorator/context values

Note: Using @track() without parameters is optional since payi_instrument() already enables automatic instrumentation. However, explicitly adding it can be useful for making code more self-documenting by visibly marking a function as instrumented while still inheriting all parameter values from parent context.

When methods are nested (like @track inside @track, or track_context inside track_context), the inner method's parameters interact with the outer method according to these rules.

Generalized Parameter Inheritance Rules by Data Type

Parameter TypeWhen Value is None or Not SpecifiedWhen Value is Empty ("", [])When Value is Non-Empty
String (e.g., user_id)Inherits from parent scopeOverrides parent value with empty stringOverrides parent value
List (e.g., limit_ids)Inherits from parent scopeOverrides parent values with empty listCombines with parent values
Integer (e.g., use_case_version)Inherits from parent scopeN/A (no empty state for integers)Overrides parent value

These rules are consistent whether you're using nested decorators, nested context managers, or combinations of decorators and context managers.

Key Takeaway on Override Behavior: When a nested context specifies a parameter value, it overrides the inherited value. The behavior depends consistently on whether the parameter is a Scalar (String, Integer) or a List:

  • Scalars (Strings, Integers): The parent value is always replaced by the new value.
    • Note: For Strings specifically, an empty string "" also acts as a replacement. Integers lack an equivalent "empty" replacement state.
  • Lists: The parent value is either combined with the new list items (if the new list is non-empty) or replaced entirely (if the new value is an empty list [] or an empty string "" in headers).

Method-Specific Differences

While parameter behavior patterns are consistent across all methods, there are important differences in how each method is applied:

CharacteristicGlobal Context@tracktrack_contextcustom_headers
PrecedenceLowestMediumHighHighest
Runtime ValuesFixed at initializationFunctions/globals available at module loadValues available when context createdValues available at API call time
Scope DurationAll application requestsAll calls in decorated function and its calltreeOnly within context blockSingle API call
Examplepayi_instrument(config={...})@track(use_case_name="feature")with track_context(user_id=current_user.id)extra_headers={"x-payi-user-id": user_response.id}

Use Cases for Empty Values

Empty values ("", []) provide a way to explicitly override parameters from parent scopes. Some practical reasons to do this:

  1. Temporarily Disabling Limits: Setting limit_ids to [] removes all limit constraints from a specific operation. This is useful for:

    • Admin operations that should bypass spending restrictions
    • Critical system messages in chatbots that shouldn't count toward user quotas
    • Fallback paths during error handling
  2. Redacting User Information: Setting user_id to "" can be used when:

    • Processing shared content that shouldn't be attributed to a specific user
    • Handling public/anonymous API endpoints within an otherwise authenticated app
    • Running system maintenance tasks that are user-agnostic

Nesting Behavior Examples

Nested @track Decorators

This example shows how parameters behave when decorated functions contain other decorated functions. The key points demonstrated are:

  • Parameters from outer decorated functions automatically flow to inner functions
  • Inner functions can override inherited parameters with either non-empty values or empty values ([], "")
  • List values combine across nested function decorators
@track(limit_ids=["limit1"])
def outer_function():
    # A function with no decorator at all
    def inner_function0():
        # No instrumentation unless the call comes through decorated parent
        # When called through outer_function:
        # - limit_ids = ["limit1"] (inherits from parent function's decorator)
        pass
        
    # Function with empty decorator - equivalent to no parameters
    @track()
    def inner_function1():
        # Result: limit_ids = ["limit1"]
        # (Inherits from parent decorator)
        pass
        
    @track(limit_ids=[])
    def inner_function2():
        # Result: limit_ids = []
        # (Empty list overrides parent values)
        pass
        
    @track(limit_ids=["limit2"])
    def inner_function3():
        # Result: limit_ids = ["limit1", "limit2"]
        # (Lists combine from both decorators)
        pass

Note: Functions with no decorator and functions with @track() or @track(limit_ids=None) behave the same way - they inherit parameter values from parent decorated functions when called through the parent's call tree.

Nested track_context Calls

Context managers can be nested to create layers of instrumentation. This pattern provides practical benefits like:

  • Adding tenant-specific limits to global app limits for multi-tenant applications
  • Temporarily applying user identity to a block of code that handles their requests
  • Associating multiple API calls with the same use case for better analytics grouping
  • Selectively clearing user attribution with empty values for system operations

These examples show how nested context managers help you track and control resource usage at different levels of your application.

with track_context(limit_ids=["limit1"], user_id="user1"):
    # First level context with specific values
    
    with track_context(limit_ids=[], user_id=""):
        # Result:
        # - limit_ids = [] (Empty list overrides outer context)
        # - user_id = "" (Empty string overrides outer context)
        pass
        
    with track_context(limit_ids=["limit2"]):
        # Result: limit_ids = ["limit1", "limit2"] (Lists combine)
        
        with track_context(limit_ids=["limit3"]):
            # Result: limit_ids = ["limit1", "limit2", "limit3"]
            # (Lists combine through multiple levels)
            pass

Combined @track and track_context

When a function has both decorator and context manager instrumentation, the context manager takes precedence following these rules:

String Parameters (user_id, use_case_name, etc.)

String values in instrumentation parameters follow standard override behavior. This predictable pattern makes it easy to set defaults at the function level via decorators, then override them selectively with context managers where needed:

  • Both empty and non-empty strings in the context manager override decorator values
  • None values in the context manager inherit from the decorator
@track(user_id="decorator_user")
def function_with_context():
    # Default user_id = "decorator_user"
    
    with track_context(user_id="context_user"):
        # Result: user_id = "context_user" (context overrides decorator)
        client.chat.completions.create(...)
    
    with track_context(user_id=None):
        # Result: user_id = "decorator_user" (None inherits from decorator)
        client.chat.completions.create(...)
    
    with track_context(user_id=""):
        # Result: user_id = "" (Empty string overrides decorator value)
        client.chat.completions.create(...)

List Parameters (limit_ids, request_tags)

List parameters interact differently with parent values than string parameters do:

  • Lists in the context manager modify parent values in two ways:
    • Non-empty lists combine with decorator values (additive behavior)
    • Empty lists ([]) replace all decorator values (override behavior)
  • None values in the context manager inherit the entire list from the decorator

This combining behavior is especially useful for accumulating limits or tags across different levels of your application.

@track(limit_ids=["decorator_limit"])
def function_with_context():
    # Default limit_ids = ["decorator_limit"]
    
    with track_context(limit_ids=["context_limit"]):
        # Result: limit_ids = ["decorator_limit", "context_limit"] (values combine)
        client.chat.completions.create(...)
    
    with track_context(limit_ids=[]):
        # Result: limit_ids = [] (Empty list overrides decorator values)
        client.chat.completions.create(...)

Numeric Parameters (use_case_version)

Numeric parameters like use_case_version follow a simple pattern:

  • Non-null values override the parent value
  • None values inherit from the parent
@track(use_case_version=1)
def function_with_numeric():
    # Default: use_case_version = 1 (from decorator)
    client.chat.completions.create(...)
    
    with track_context(use_case_version=2):
        # Result: use_case_version = 2 (Numeric value overrides decorator)
        client.chat.completions.create(...)

Name-ID Pairs (use_case_name/id, experience_name/id)

Name-ID pairs like use_case_name/use_case_id or experience_name/experience_id have special handling because they're related parameters:

  • When only a name is specified, a new ID is automatically generated (unless explicitly provided)
  • When only an ID is specified, the name from the parent scope is used with the new ID
  • When both are specified, they completely override the parent values
  • None values inherit the parent values
  • Empty string values for names override inheritance and set to empty

This provides flexibility when working with related parameter pairs that need to be kept in sync.

@track(use_case_name="decorator_case", use_case_id="decorator_id")
def function_with_context():
    # Default use_case_name = "decorator_case", use_case_id = "decorator_id"
    
    with track_context(use_case_name="context_case"):
        # Result:
        # - use_case_name = "context_case"
        # - use_case_id = new UUID
        # (New name gets new ID unless explicitly provided)
        client.chat.completions.create(...)
    
    with track_context(use_case_id="context_id"):
        # Result:
        # - use_case_name = "decorator_case"
        # - use_case_id = "context_id"
        # (Decorator name used with context ID)
        client.chat.completions.create(...)
    
    with track_context(use_case_name="context_case", use_case_id="context_id"):
        # Result:
        # - use_case_name = "context_case"
        # - use_case_id = "context_id"
        # (Context values override decorator completely)
        client.chat.completions.create(...)

Headers with Decorated Functions

Custom headers provide direct parameter control at the individual API call level. They can be used standalone on any API call or in combination with active @track decorators or track_context scopes. When used standalone (i.e., no active decorator or context scope), there are no decorator/context values to inherit from or combine with. When used in combination, they interact with inherited values as follows:

  • String Parameters (e.g., x-payi-user-id): Both empty ("") and non-empty string values in headers override inherited values for that specific API call.
  • List Parameters (e.g., x-payi-limit-ids):
    • An empty string ("") in the header overrides the inherited list entirely, resulting in an empty list ([]) for that call.
    • A non-empty string in the header is treated as a single element and combined with the inherited list values.
  • Numeric Types (e.g., x-payi-use-case-version): Values override inherited values.
  • Unspecified Parameters: If a parameter is not included in extra_headers, its value is determined by the active decorator/context scope.
  • Scope: Header parameters only affect the specific API call they are attached to.

This makes headers ideal for one-off parameter overrides without changing the surrounding context.

@track(limit_ids=["limit1"], user_id="default_user")
def function_with_headers():
    # Headers with empty values:
    client.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": "Hello"}],
        extra_headers={
            "x-payi-limit-ids": "",  # Empty string for list parameter
            "x-payi-user-id": ""     # Empty string for string parameter
        }
    )
    # Result:
    # - limit_ids = [] (Empty string in header overrides decorator)
    # - user_id = "" (Empty string in header overrides decorator)
    
    # Headers with non-empty values:
    client.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": "Hello"}],
        extra_headers={
            "x-payi-limit-ids": "header_limit",  # Non-empty list value
            "x-payi-use-case-version": "5"       # Numeric parameter
        }
    )
    # Result:
    # - limit_ids = ["limit1", "header_limit"] (Combined)
    # - use_case_version = 5 (Header overrides any inherited value)
    # - user_id = "default_user" (Inherited from decorator as not in headers)

Context Manager Calling a Decorated Function

When a decorated function is called from within a context manager:

  • The context manager's values take precedence over the decorator's values
  • String values from the context manager override the decorator's values
  • List values from both sources are combined

This is a particularly important pattern as it allows you to apply function-specific defaults via decorators, then override or enhance them for specific execution contexts.

@track(limit_ids=["decorator_limit"], user_id="decorator_user")
def decorated_function():
    # When called directly:
    # - limit_ids = ["decorator_limit"]
    # - user_id = "decorator_user"
    client.chat.completions.create(...)

# When called from within a track_context:
with track_context(limit_ids=["context_limit"], user_id="context_user"):
    # Context manager values have higher precedence
    decorated_function()
    # Result inside the function's API calls:
    # - limit_ids = ["decorator_limit", "context_limit"] (combined)
    # - user_id = "context_user" (context overrides decorator)

Triple Combination Example

This example demonstrates the most complex nesting scenario combining all three instrumentation methods: decorator, context manager, and custom headers. It also shows nested context managers with different parameter values to illustrate:

  1. How parameters flow through multiple levels of instrumentation
  2. The consistent behavior of parameter inheritance rules at each level
  3. How empty values can override inherited values at any level
  4. How list values combine across all active scopes
  5. The effect of adding custom headers at the API call level
@track(limit_ids=["limit1"], user_id="default_user")
def complex_function():
    # Starting parameters from decorator:
    # - limit_ids = ["limit1"]
    # - user_id = "default_user"
    
    with track_context(limit_ids=["limit2"], use_case_name="reporting"):
        # First-level context adds:
        # - limit_ids = ["limit1", "limit2"] (combined from decorator and context)
        # - user_id = "default_user" (from decorator, not overridden)
        # - use_case_name = "reporting" (from context)
        
        # Example 1: Direct API call with headers
        client.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": "Hello"}],
            extra_headers={
                "x-payi-limit-ids": "limit3",
                "x-payi-user-id": "specific_user"
            }
        )
        # Parameters for this call:
        # - limit_ids = ["limit1", "limit2", "limit3"] (combined from all three)
        # - user_id = "specific_user" (headers override both decorator and context)
        # - use_case_name = "reporting" (from context, not overridden by headers)
        
        # Example 2: Nested context with empty values (overriding values)
        with track_context(limit_ids=[], user_id=""):
            # Parameters in this nested context:
            # - limit_ids = [] (empty list overrides inherited values)
            # - user_id = "" (empty string overrides inherited value)
            # - use_case_name = "reporting" (still inherited as not overridden)
            client.chat.completions.create(...)
            
        # Example 3: Nested context with new values
        with track_context(limit_ids=["limit4"], user_id="nested_user"):
            # Parameters in this nested context:
            # - limit_ids = ["limit1", "limit2", "limit4"] (combines through all levels)
            # - user_id = "nested_user" (overrides previously inherited value)
            # - use_case_name = "reporting" (still inherited as not overridden)
            client.chat.completions.create(...)

This confirms that parameter behavior is consistent regardless of nesting depth. The same rules apply whether you're working with a simple decorator or a complex multi-level combination of all instrumentation methods.