Skip to content

Propagate context for some stored procedures #6799

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Apr 1, 2025
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
af6b991
Propagate context for stored procedures
bouwkast Mar 24, 2025
72dfe00
Inject comments in stored procedures
bouwkast Mar 24, 2025
7fb1471
Only support StoredProcedure propagation on SqlServer
bouwkast Mar 25, 2025
1f01d2e
Add OUTPUT for output parameters
bouwkast Mar 25, 2025
f411e22
Comment cleanup
bouwkast Mar 25, 2025
69c7b81
Fix setting whether we injected DBM
bouwkast Mar 25, 2025
56eec08
Add missing comma
bouwkast Mar 25, 2025
7ce1163
Remove comment causing build to fail
bouwkast Mar 25, 2025
68e6ca1
Add stored procedure tests
bouwkast Mar 25, 2025
9bd8db5
Update snapshots to include stored procedures
bouwkast Mar 26, 2025
c71147c
Don't use SqlDbType
bouwkast Mar 26, 2025
47dffd4
Handle edge cases for other StoredProcedure and no Params
bouwkast Mar 26, 2025
7746ec2
Allow for stored proceudres to be not injected
bouwkast Mar 26, 2025
ee0ff12
Add unit tests for StoredProcedure to EXEC
bouwkast Mar 26, 2025
f77b1be
Rescope to Input and parameterless and fix naming
bouwkast Mar 27, 2025
56e5c7c
Default to not inject into stored procedure
bouwkast Mar 27, 2025
d461cb2
Document limitations with Stored Procedure injection
bouwkast Mar 27, 2025
8872b96
Don't throw, just return empty
bouwkast Mar 27, 2025
914b69a
Cleanup and refactoring
bouwkast Mar 27, 2025
571092e
Update documentation for stored procedures context injection
bouwkast Apr 1, 2025
8bbf1f5
Fix log debug
bouwkast Apr 1, 2025
d640cc2
Simplify if
bouwkast Apr 1, 2025
436c29c
Some basic rate limiting of logs
bouwkast Apr 1, 2025
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
146 changes: 146 additions & 0 deletions docs/development/StoredProcedureInjection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Stored Procedure DBM Propagation Injection

## Overview

When attempting to instrument SQL Server stored procedure executions with database monitoring (DBM) information, we faced a limitation that required us to modify the command from `CommandType.StoredProcedure` to `CommandType.Text`. While this change works for `Input` parameters, it fails for parameters with `ParameterDirection` values of `Output`, `InputOutput`, or `ReturnValue`. This document explains the technical reasons for this limitation and why we implemented a restriction to only convert stored procedures that have exclusively `Input` parameters.

## Technical Background

### How ADO.NET Executes Commands

ADO.NET provides two primary methods for executing database commands:

1. **RPC (Remote Procedure Call)** - Used for stored procedures, this mechanism sends the procedure name and parameters directly to SQL Server as an RPC call.
2. **SQL Batch** - Used for SQL text, this sends the entire command text to be parsed and executed by SQL Server.

When a `SqlCommand` is executed with `CommandType.StoredProcedure`, the internal execution path differs significantly from when it's executed with `CommandType.Text`:

```csharp
// For StoredProcedure
BuildRPC(inSchema, _parameters, ref rpc); // Builds the RPC call format

// For Text
BuildExecuteSql(cmdBehavior, null, _parameters, ref rpc); // Builds SQL batch format
```

### Why Can't We Inject into the StoredProcedure CommandText?

A fundamental issue is that with `CommandType.StoredProcedure`, the `CommandText` property is used as the **procedure name** itself, not as a SQL command to be executed. Examining the source code in `SqlCommand.cs`, we can see:

```csharp
Debug.Assert(this.CommandType == System.Data.CommandType.StoredProcedure, "invalid use of sp_prepare for stored proc invocation!");
// ...
rpc.rpcName = this.CommandText; // just get the raw command text
```

If we were to inject DBM comments into the `CommandText` for a stored procedure, the resulting value wouldn't be a valid procedure name:

```csharp

Debug.Assert(this.CommandType == System.Data.CommandType.StoredProcedure, "invalid use of sp_prepare for stored proc invocation!");
// ...
rpc.rpcName = this.CommandText; // just get the raw command text
```

This would result in SQL Server errors because it would try to find a procedure literally named `/*dddbs='service'*/ dbo.GetData` which doesn't exist.

### The Parameter Direction Challenge

The key issue is how parameters with different directions are handled:

#### For Stored Procedures (RPC):

- The ADO.NET driver sets up the parameter directions properly in the TDS protocol
- SQL Server explicitly returns values for `Output`, `InputOutput`, and `ReturnValue` parameters
- The driver automatically updates the `.Value` property of these parameters with the returned values

From `SqlCommand.cs`:

```csharp
// set output bit
if (parameter.Direction == ParameterDirection.InputOutput ||
parameter.Direction == ParameterDirection.Output)
rpc.paramoptions[j] |= TdsEnums.RPC_PARAM_BYREF;
```
#### For Text Commands:

- The parameters are sent as part of the SQL batch via parameter substitution
- There's no built-in mechanism to capture output values back into the original parameters
- The `.Value` property of parameters doesn't automatically update

## The Conversion Process

When instrumenting database calls for DBM, we must add tracking comments to the command:

```csharp
// Original stored procedure call
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "dbo.sp_GetTableRow";

// Converted to text command with DBM comment
command.CommandType = CommandType.Text;
command.CommandText = "EXEC [dbo].[sp_GetTableRow] @Param1=@Param1 /*dddbs='service'...*/";
```

This conversion works perfectly for `Input` parameters, as we're simply passing the same values in a different format.

## The Core Issue

**When converting from `StoredProcedure` to `Text`, the automatic parameter value updating is lost.**

For a standard stored procedure call, SQL Server returns output parameter values, and ADO.NET's internal mechanisms update the parameter objects automatically. When we convert to a text command, our `EXEC` statement executes the procedure correctly, but there's no mechanism to capture these output values back to the original parameters.

This occurs because:

1. The TDS protocol handling differs between RPC and SQL batch execution
2. When using `CommandType.Text`, parameter values are substituted into the SQL string
3. The connection between the parameter objects and their output values is lost in the conversion

### Example of the Problem

```csharp
// Original code
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "dbo.UpdateRecord";
var outParam = command.Parameters.AddWithValue("@OldValue", SqlDbType.VarChar, 100);
outParam.Direction = ParameterDirection.Output;
command.ExecuteNonQuery();
string oldValue = outParam.Value.ToString(); // This works!

// After our conversion
command.CommandType = CommandType.Text;
command.CommandText = "EXEC [dbo].[UpdateRecord] @OldValue=@OldValue OUTPUT /*dddbs='service'...*/";
command.ExecuteNonQuery();
string oldValue = outParam.Value.ToString(); // This returns the original value, not the output!
```

## Implementation Decision

Given this limitation, we implemented a safety check:

```csharp
// Check to see if we have any Return/InputOutput/Output parameters
if (command.Parameters != null) {
foreach (DbParameter? param in command.Parameters) {
if (param == null) {
continue;
}

if (param.Direction != ParameterDirection.Input) {
return false; // Do not attempt the conversion
}
}
}
```

This prevents us from silently breaking applications that depend on output parameters. Instead, we leave such commands unmodified, sacrificing DBM tracing for those specific calls to ensure application correctness.

## Conclusion

The limitation in converting stored procedure calls with non-input parameters to text commands is a fundamental issue with how ADO.NET and the TDS protocol handle different command types. While it would be technically possible to implement a custom solution that executes the procedure and then separately retrieves output values, such an approach would:

1. Add significant complexity
2. Require multiple round-trips to the database
3. Potentially introduce transaction and isolation level issues

Given these challenges, the current approach of only instrumenting stored procedures with exclusively input parameters represents the best balance between providing monitoring capabilities and ensuring application reliability.
2 changes: 2 additions & 0 deletions tracer/missing-nullability-files.csv
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ src/Datadog.Trace/ContinuousProfiler/IProfilerStatus.cs
src/Datadog.Trace/ContinuousProfiler/NativeInterop.cs
src/Datadog.Trace/ContinuousProfiler/Profiler.cs
src/Datadog.Trace/ContinuousProfiler/ProfilerStatus.cs
src/Datadog.Trace/DatabaseMonitoring/MultiPartIdentifier.cs
src/Datadog.Trace/DatabaseMonitoring/VendoredSqlHelpers.cs
Comment on lines +105 to +106
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should be marked as nullable

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, my bad, it's vendored and they didn't mark it nullable. grmbl

src/Datadog.Trace/DataStreamsMonitoring/CheckpointKind.cs
src/Datadog.Trace/Debugger/ILineProbeResolver.cs
src/Datadog.Trace/Debugger/LiveDebugger.cs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@
<type fullname="System.Data.CommandType" />
<type fullname="System.Data.Common.DbCommand" />
<type fullname="System.Data.Common.DbConnectionStringBuilder" />
<type fullname="System.Data.Common.DbParameter" />
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed for building up stored procedure as EXEC

<type fullname="System.Data.ConnectionState" />
<type fullname="System.Data.DbType" />
<type fullname="System.Data.IDataParameter" />
Expand All @@ -220,6 +221,7 @@
<type fullname="System.Data.IDbConnection" />
<type fullname="System.Data.IDbDataParameter" />
<type fullname="System.Data.IDbTransaction" />
<type fullname="System.Data.ParameterDirection" />
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed for building up stored procedure as EXEC

</assembly>
<assembly fullname="System.Data.SqlClient" />
<assembly fullname="System.Data.SQLite" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,7 @@ internal static class DbScopeFactory

try
{
if (tracer.Settings.DbmPropagationMode != DbmPropagationLevel.Disabled
&& command.CommandType != CommandType.StoredProcedure)
if (tracer.Settings.DbmPropagationMode != DbmPropagationLevel.Disabled)
{
var alreadyInjected = commandText.StartsWith(DatabaseMonitoringPropagator.DbmPrefix) ||
// if we appended the comment, we need to look for a potential DBM comment in the whole string.
Expand All @@ -102,10 +101,12 @@ internal static class DbScopeFactory
}
else
{
var traceParentInjectedInComment = DatabaseMonitoringPropagator.PropagateDataViaComment(tracer.Settings.DbmPropagationMode, integrationId, command, tracer.DefaultServiceName, tagsFromConnectionString.DbName, tagsFromConnectionString.OutHost, scope.Span);

// PropagateDataViaComment (service) - this injects varius trace information as a comment in the query
// PropagateDataViaContext (full) - this makes a special set context_info for Microsoft SQL Server (nothing else supported)
var traceParentInjectedInComment = DatabaseMonitoringPropagator.PropagateDataViaComment(tracer.Settings.DbmPropagationMode, integrationId, command, tracer.DefaultServiceName, tagsFromConnectionString.DbName, tagsFromConnectionString.OutHost, scope.Span, tracer.Settings.InjectContextIntoStoredProceduresEnabled);
// try context injection only after comment injection, so that if it fails, we still have service level propagation
var traceParentInjectedInContext = DatabaseMonitoringPropagator.PropagateDataViaContext(tracer.Settings.DbmPropagationMode, integrationId, command, scope.Span);

if (traceParentInjectedInComment || traceParentInjectedInContext)
{
tags.DbmTraceInjected = "true";
Expand Down
8 changes: 8 additions & 0 deletions tracer/src/Datadog.Trace/Configuration/ConfigurationKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,14 @@ internal static class FeatureFlags
internal const string CommandsCollectionEnabled = "DD_TRACE_COMMANDS_COLLECTION_ENABLED";

public const string BypassHttpRequestUrlCachingEnabled = "DD_TRACE_BYPASS_HTTP_REQUEST_URL_CACHING_ENABLED";

/// <summary>
/// Configuration key to enable or disable the injection of the Datadog trace context into stored procedures.
/// Default value is <c>false</c> (enabled).
/// This only applies to StoredProcedures for Microsoft SQL Server when <see cref="DbmPropagationMode"/> is set to <c>service</c> or <c>full</c>.
/// This changes stored procedures that do not have <c>Output</c>/<c>InputOutput</c>/<c>Return</c> ADO.NET command parameters to their <c>EXEC</c> counterparts to inject context as a comment.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a suggested rewording 😄

Suggested change
/// This only applies to StoredProcedures for Microsoft SQL Server when <see cref="DbmPropagationMode"/> is set to <c>service</c> or <c>full</c>.
/// This changes stored procedures that do not have <c>Output</c>/<c>InputOutput</c>/<c>Return</c> ADO.NET command parameters to their <c>EXEC</c> counterparts to inject context as a comment.
/// When enabled, Datadog trace context will be injected into individual stored procedure calls when the following requirements are met:
/// <ul>
/// <li>The database is Microsoft SQL Server and <see cref="DbmPropagationMode"/> is set to <c>service</c> or <c>full</c>.</li>
/// <li>The stored procedure call does not have <c>Output</c>, <c>InputOutput</c>, or <c>Return</c> ADO.NET command parameters.
/// </ul>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe my details are not accurate, but I was thinking a slightly revised structure could help with clarity

/// </summary>
public const string InjectContextIntoStoredProceduresEnabled = "DD_TRACE_INJECT_CONTEXT_INTO_STORED_PROCEDURES_ENABLED";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget to add this to the config_norm file in go

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

internal static class Telemetry
Expand Down
10 changes: 10 additions & 0 deletions tracer/src/Datadog.Trace/Configuration/TracerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,9 @@ _ when x.ToBoolean() is { } boolean => boolean,
BypassHttpRequestUrlCachingEnabled = config.WithKeys(ConfigurationKeys.FeatureFlags.BypassHttpRequestUrlCachingEnabled)
.AsBool(false);

InjectContextIntoStoredProceduresEnabled = config.WithKeys(ConfigurationKeys.FeatureFlags.InjectContextIntoStoredProceduresEnabled)
.AsBool(false);

var defaultDisabledAdoNetCommandTypes = new string[] { "InterceptableDbCommand", "ProfiledDbCommand" };
var userDisabledAdoNetCommandTypes = config.WithKeys(ConfigurationKeys.DisabledAdoNetCommandTypes).AsString();

Expand Down Expand Up @@ -1177,6 +1180,13 @@ public bool DiagnosticSourceEnabled
/// </summary>
internal bool BypassHttpRequestUrlCachingEnabled { get; }

/// <summary>
/// Gets a value indicating whether the tracer will inject context into
/// StoredProcedure commands for Microsoft SQL Server.
/// Requires the <see cref="DbmPropagationMode"/> to be set to either <see cref="DbmPropagationLevel.Service"/> or <see cref="DbmPropagationLevel.Full"/>.
/// </summary>
internal bool InjectContextIntoStoredProceduresEnabled { get; }

/// <summary>
/// Gets the AAS settings
/// </summary>
Expand Down
Loading
Loading