Skip to content

Commit 4f1d085

Browse files
Add OpenTelemetry instrumentation to the data portal (#4736)
* Initial plan * Add OpenTelemetryDashboard class and required dependencies Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> * Add tests and documentation for OpenTelemetryDashboard Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> * Add README for DataPortalInstrumentation sample with OpenTelemetry info Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> * Fix build error: replace C# 12 collection expression with compatible syntax Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> * Fix build error: add missing using statements for System namespaces Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> * Fix build error: explicitly name parameter in CreateCounter and CreateHistogram calls Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> * Fix unit tests: use supported DataPortalResult constructor with ApplicationContext Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> * Fix test that fails on a local run --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com> Co-authored-by: Rockford Lhotka <rocky@lhotka.net>
1 parent de4236b commit 4f1d085

6 files changed

Lines changed: 578 additions & 11 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Data Portal Instrumentation Sample
2+
3+
This sample demonstrates how to use the CSLA data portal dashboard features for monitoring and observability.
4+
5+
## Dashboard Options
6+
7+
CSLA provides multiple dashboard implementations:
8+
9+
### 1. Default Dashboard (`Csla.Server.Dashboard.Dashboard`)
10+
11+
The default dashboard maintains in-memory metrics and a recent activity queue.
12+
13+
```csharp
14+
builder.Services.AddCsla(o => o
15+
.AddAspNetCore()
16+
.DataPortal(dp => dp
17+
.AddServerSideDataPortal(ss => ss
18+
.RegisterDashboard<Csla.Server.Dashboard.Dashboard>())));
19+
```
20+
21+
Access dashboard data via the `IDashboard` interface:
22+
- `TotalCalls` - Total number of data portal calls
23+
- `CompletedCalls` - Number of successful calls
24+
- `FailedCalls` - Number of failed calls
25+
- `GetRecentActivity()` - Returns a list of recent data portal operations
26+
27+
### 2. OpenTelemetry Dashboard (`Csla.Server.Dashboard.OpenTelemetryDashboard`)
28+
29+
The OpenTelemetry dashboard exports metrics to OpenTelemetry collectors for integration with modern observability platforms like Prometheus, Grafana, Azure Monitor, and .NET Aspire.
30+
31+
```csharp
32+
using OpenTelemetry.Metrics;
33+
34+
// Configure OpenTelemetry
35+
builder.Services.AddOpenTelemetry()
36+
.WithMetrics(metrics => metrics
37+
.AddMeter("Csla.DataPortal")
38+
.AddPrometheusExporter() // or other exporters
39+
);
40+
41+
// Configure CSLA with OpenTelemetry Dashboard
42+
builder.Services.AddCsla(o => o
43+
.AddAspNetCore()
44+
.DataPortal(dp => dp
45+
.AddServerSideDataPortal(ss => ss
46+
.RegisterDashboard<Csla.Server.Dashboard.OpenTelemetryDashboard>())));
47+
```
48+
49+
Metrics available:
50+
- `csla.dataportal.calls.total` - Counter for total calls
51+
- `csla.dataportal.calls.completed` - Counter for successful calls
52+
- `csla.dataportal.calls.failed` - Counter for failed calls
53+
- `csla.dataportal.call.duration` - Histogram of call durations in milliseconds
54+
55+
All metrics include tags for `object.type` and `operation` to enable filtering and aggregation.
56+
57+
### 3. Null Dashboard (`Csla.Server.Dashboard.NullDashboard`)
58+
59+
A no-op dashboard that records nothing, for production environments where monitoring is not needed.
60+
61+
```csharp
62+
builder.Services.AddCsla(o => o
63+
.AddAspNetCore()
64+
.DataPortal(dp => dp
65+
.AddServerSideDataPortal(ss => ss
66+
.RegisterDashboard<Csla.Server.Dashboard.NullDashboard>())));
67+
```
68+
69+
## Running the Sample
70+
71+
1. Update `Program.cs` to use the desired dashboard implementation
72+
2. Run the application
73+
3. Navigate to the app and trigger some data portal operations
74+
4. For the default dashboard, access the dashboard via the API controller at `/api/DataPortal`
75+
5. For OpenTelemetry dashboard, use your configured metrics backend to query the metrics
76+
77+
## See Also
78+
79+
- [OpenTelemetry Dashboard Documentation](../../../docs/OpenTelemetry-Dashboard.md)
80+
- [CSLA Data Portal](https://cslanet.com/docs/DataPortal)

Source/Csla/Csla.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
<PackageReference Include="System.ComponentModel.Primitives" Version="4.3.0" />
7171
<PackageReference Include="System.Reflection" Version="4.3.0" />
7272
<PackageReference Include="System.Runtime.Serialization.Formatters" Version="4.3.0" />
73+
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="8.0.0" />
7374
<PackageReference Include="Polyfill" Version="7.4.0">
7475
<PrivateAssets>all</PrivateAssets>
7576
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
@@ -116,6 +117,7 @@
116117
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
117118
<PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" />
118119
<PackageReference Include="System.Runtime.Loader" Version="4.3.0" />
120+
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="8.0.0" />
119121
<PackageReference Include="Polyfill" Version="7.4.0">
120122
<PrivateAssets>all</PrivateAssets>
121123
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//-----------------------------------------------------------------------
2+
// <copyright file="OpenTelemetryDashboard.cs" company="Marimer LLC">
3+
// Copyright (c) Marimer LLC. All rights reserved.
4+
// Website: https://cslanet.com
5+
// </copyright>
6+
// <summary>OpenTelemetry dashboard implementation for data portal</summary>
7+
//-----------------------------------------------------------------------
8+
9+
using System;
10+
using System.Collections.Generic;
11+
using System.Diagnostics;
12+
using System.Diagnostics.Metrics;
13+
using System.Threading;
14+
15+
namespace Csla.Server.Dashboard
16+
{
17+
/// <summary>
18+
/// Data portal server dashboard that reports metrics
19+
/// using OpenTelemetry instrumentation.
20+
/// </summary>
21+
public class OpenTelemetryDashboard : IDashboard
22+
{
23+
private readonly Meter _meter;
24+
private readonly Counter<long> _totalCallsCounter;
25+
private readonly Counter<long> _completedCallsCounter;
26+
private readonly Counter<long> _failedCallsCounter;
27+
private readonly Histogram<double> _callDurationHistogram;
28+
29+
/// <summary>
30+
/// Gets the OpenTelemetry meter name for CSLA data portal metrics.
31+
/// </summary>
32+
public const string MeterName = "Csla.DataPortal";
33+
34+
/// <summary>
35+
/// Gets the OpenTelemetry meter version.
36+
/// </summary>
37+
public const string MeterVersion = "1.0.0";
38+
39+
/// <summary>
40+
/// Creates an instance of the type.
41+
/// </summary>
42+
public OpenTelemetryDashboard()
43+
{
44+
_meter = new Meter(MeterName, MeterVersion);
45+
46+
_totalCallsCounter = _meter.CreateCounter<long>(
47+
name: "csla.dataportal.calls.total",
48+
unit: "{call}",
49+
description: "Total number of data portal calls");
50+
51+
_completedCallsCounter = _meter.CreateCounter<long>(
52+
name: "csla.dataportal.calls.completed",
53+
unit: "{call}",
54+
description: "Number of successfully completed data portal calls");
55+
56+
_failedCallsCounter = _meter.CreateCounter<long>(
57+
name: "csla.dataportal.calls.failed",
58+
unit: "{call}",
59+
description: "Number of failed data portal calls");
60+
61+
_callDurationHistogram = _meter.CreateHistogram<double>(
62+
name: "csla.dataportal.call.duration",
63+
unit: "ms",
64+
description: "Duration of data portal calls in milliseconds");
65+
66+
FirstCall = DateTimeOffset.Now;
67+
}
68+
69+
/// <summary>
70+
/// Gets the time the data portal was first invoked
71+
/// </summary>
72+
public DateTimeOffset FirstCall { get; }
73+
74+
/// <summary>
75+
/// Gets the most recent time the data portal
76+
/// was invoked
77+
/// </summary>
78+
public DateTimeOffset LastCall { get; private set; }
79+
80+
private long _totalCalls;
81+
/// <summary>
82+
/// Gets the total number of times the data portal
83+
/// has been invoked
84+
/// </summary>
85+
public long TotalCalls => Interlocked.Read(ref _totalCalls);
86+
87+
private long _completedCalls;
88+
/// <summary>
89+
/// Gets the number of times data portal
90+
/// calls have successfully completed
91+
/// </summary>
92+
public long CompletedCalls => Interlocked.Read(ref _completedCalls);
93+
94+
private long _failedCalls;
95+
/// <summary>
96+
/// Gets the number of times data portal
97+
/// calls have failed
98+
/// </summary>
99+
public long FailedCalls => Interlocked.Read(ref _failedCalls);
100+
101+
/// <summary>
102+
/// Gets the items in the recent activity queue.
103+
/// </summary>
104+
public List<Activity> GetRecentActivity()
105+
{
106+
return new List<Activity>();
107+
}
108+
109+
/// <inheritdoc />
110+
void IDashboard.InitializeCall(InterceptArgs e)
111+
{
112+
if (e is null)
113+
throw new ArgumentNullException(nameof(e));
114+
115+
LastCall = DateTimeOffset.Now;
116+
Interlocked.Add(ref _totalCalls, 1);
117+
118+
var tags = new TagList
119+
{
120+
{ "object.type", e.ObjectType.Name },
121+
{ "operation", e.Operation.ToString() }
122+
};
123+
124+
_totalCallsCounter.Add(1, tags);
125+
}
126+
127+
/// <inheritdoc />
128+
void IDashboard.CompleteCall(InterceptArgs e)
129+
{
130+
if (e is null)
131+
throw new ArgumentNullException(nameof(e));
132+
133+
var tags = new TagList
134+
{
135+
{ "object.type", e.ObjectType.Name },
136+
{ "operation", e.Operation.ToString() }
137+
};
138+
139+
if (e.Exception != null)
140+
{
141+
Interlocked.Add(ref _failedCalls, 1);
142+
tags.Add("exception.type", e.Exception.GetType().Name);
143+
_failedCallsCounter.Add(1, tags);
144+
}
145+
else
146+
{
147+
Interlocked.Add(ref _completedCalls, 1);
148+
_completedCallsCounter.Add(1, tags);
149+
}
150+
151+
_callDurationHistogram.Record(e.Runtime.TotalMilliseconds, tags);
152+
}
153+
154+
/// <summary>
155+
/// Dispose resources used by this object.
156+
/// </summary>
157+
public void Dispose()
158+
{
159+
_meter?.Dispose();
160+
}
161+
}
162+
}

Source/tests/Csla.test/DataPortal/DataPortalTests.cs

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
using Csla.Server;
2020
using System.Security.Principal;
2121
using FluentAssertions.Execution;
22+
using System.ComponentModel; // added
2223

2324
namespace Csla.Test.DataPortal
2425
{
@@ -70,13 +71,19 @@ public void TestTransactionScopeUpdate()
7071
TransactionalRoot tr = TransactionalRoot.NewTransactionalRoot(dataPortal);
7172
tr.FirstName = "Bill";
7273
tr.LastName = "Johnson";
73-
//setting smallColumn to a string less than or equal to 5 characters will
74-
//not cause the transaction to rollback
7574
tr.SmallColumn = "abc";
7675

77-
tr = tr.Save();
76+
try
77+
{
78+
tr = tr.Save();
79+
}
80+
catch (Exception ex)
81+
{
82+
if (IsTransactionScopeEnvironmentUnavailable(ex))
83+
Assert.Inconclusive("Skipping TransactionScope test: transactional infrastructure (MSDTC / distributed transactions) not available (0x89c5010a).");
84+
throw;
85+
}
7886

79-
// TODO: These connection strings got lost, so I've tried to recreate them, but not sure how to do this
8087
SqlConnection cn = new SqlConnection(CONNECTION_STRING);
8188
SqlCommand cm = new SqlCommand("SELECT * FROM Table2", cn);
8289

@@ -85,7 +92,6 @@ public void TestTransactionScopeUpdate()
8592
cn.Open();
8693
SqlDataReader dr = cm.ExecuteReader();
8794

88-
//will have rows since no sqlexception was thrown on the insert
8995
Assert.AreEqual(true, dr.HasRows);
9096
dr.Close();
9197
}
@@ -103,28 +109,24 @@ public void TestTransactionScopeUpdate()
103109
TransactionalRoot tr2 = TransactionalRoot.NewTransactionalRoot(dataPortal);
104110
tr2.FirstName = "Jimmy";
105111
tr2.LastName = "Smith";
106-
//intentionally input a string longer than varchar(5) to
107-
//cause a sql exception and rollback the transaction
108112
tr2.SmallColumn = "this will cause a sql exception";
109113

110114
try
111115
{
112-
//will throw a sql exception since the SmallColumn property is too long
113116
tr2 = tr2.Save();
114117
}
115118
catch (Exception ex)
116119
{
120+
if (IsTransactionScopeEnvironmentUnavailable(ex))
121+
Assert.Inconclusive("Skipping TransactionScope test: transactional infrastructure (MSDTC / distributed transactions) not available (0x89c5010a).");
117122
Assert.IsTrue(ex.Message.StartsWith("DataPortal.Update failed"), "Invalid exception message");
118123
}
119124

120-
//within the DataPortal_Insert method, two commands are run to insert data into
121-
//the database. Here we verify that both commands have been rolled back
122125
try
123126
{
124127
cn.Open();
125128
SqlDataReader dr = cm.ExecuteReader();
126129

127-
//should not have rows since both commands were rolled back
128130
Assert.AreEqual(false, dr.HasRows);
129131
dr.Close();
130132
}
@@ -141,6 +143,22 @@ public void TestTransactionScopeUpdate()
141143
}
142144
#endif
143145

146+
private static bool IsTransactionScopeEnvironmentUnavailable(Exception ex)
147+
{
148+
// Walk inner exceptions to find Win32Exception 0x89c5010a
149+
while (ex != null)
150+
{
151+
if (ex is Win32Exception w32)
152+
{
153+
if (w32.NativeErrorCode == unchecked((int)0x89c5010a) ||
154+
w32.Message.Contains("0x89c5010a", StringComparison.OrdinalIgnoreCase))
155+
return true;
156+
}
157+
ex = ex.InnerException;
158+
}
159+
return false;
160+
}
161+
144162
[TestMethod]
145163
public void StronglyTypedDataPortalMethods()
146164
{

0 commit comments

Comments
 (0)