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
15 changes: 11 additions & 4 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ jobs:
run:
working-directory: Sdk
steps:
- uses: actions/checkout@v2
# not 'lfs: true' - that pulls every lfs object at HEAD (~319MB)
- uses: actions/checkout@v4

# the unit tests play back a real ibt file
- name: fetch ibt test data
working-directory: ${{ github.workspace }}
run: git lfs pull --include "Sdk/tests/SmokeTests/data/ibt/raygr22_roadatlanta full 2025-07-15.ibt"

- name: setup
uses: actions/setup-dotnet@v4
Expand All @@ -23,7 +29,8 @@ jobs:
- name: unit tests
run: dotnet run --no-restore -c release --project ./tests/UnitTests/UnitTests.csproj

# can't run IBT tests with github runers - because we don't have
# access to memory mapped files on the host
# the ibt smoke tests are not run here. they do pass headlessly on a runner (they
# only read ibt files - no iRacing needed), but the 3 files they sweep are ~191MB
# of lfs data per run. to enable, fetch them above and add:
# - name: ibt tests
# run: dotnet test --no-restore -c release --filter IBT_Tests
# run: dotnet run --no-restore -c release --no-launch-profile --project ./tests/SmokeTests/SmokeTests.csproj -- --filter-trait "Category=ibt"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
*.user
.vs
TestResults/
artifacts/
bin/
dist/
obj/


1 change: 1 addition & 0 deletions NUGET.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ High-performance .NET SDK for accessing **live telemetry data** from iRacing sim

- **Type Safety**: Enum-based telemetry variables with IntelliSense/Copilot support and compile-time validation
- **High Performance**: Processes 600,000+ telemetry records/second with lock-free data streaming architecture
- **Low Per-Frame Overhead**: Under 0.01 ms of SDK work per telemetry update - leaving over 99.9% of iRacing's 16.67 ms (60 Hz) frame budget to your code
- **Background Processing**: Dedicated threads for telemetry collection and processing - your app's processing speed never blocks the streaming telemetry data
- **Modern Async API**: Async data streams with async/await patterns, bounded buffering, and automatic overload handling
- **Dynamic Variable Lookup**: Look up any telemetry variable by name at runtime with `GetValue(string)` - no compile-time struct required
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Perfect for building **real-time dashboards**, **data analysis tools**, **race e
- [Samples](#samples)
- [Advanced Usage](./docs/ADVANCED.md)
- [Architecture and Design](./docs/ARCHITECTURE.md)
- [Metrics and Diagnostics](./docs/METRICS.md)
- [Documentation](#documentation)
- [AI-Assisted Development](#ai-assisted-development)
- [Building from Source](#building-from-source)
Expand All @@ -28,6 +29,7 @@ Perfect for building **real-time dashboards**, **data analysis tools**, **race e

- **Type Safety**: Enum-based telemetry variables with IntelliSense support and compile-time validation
- **High Performance**: Processes 600,000+ telemetry records/second with lock-free data streaming architecture
- **Low Per-Frame Overhead**: Under 0.01 ms of SDK work per telemetry update - leaving over 99.9% of iRacing's 16.67 ms (60 Hz) frame budget to your code
- **Background Processing**: Dedicated threads for telemetry collection and processing - your app's processing speed never blocks the streaming telemetry data
- **Live Telemetry**: Real-time access to 200+ variables including speed, RPM, tire data during iRacing sessions
- **IBT File Playback**: Analyze historical telemetry using the same API as live data
Expand Down Expand Up @@ -65,7 +67,7 @@ This SDK takes a different approach. It treats telemetry as a **typed, high-thro
- **Minimal-allocation reads** — each sample is copied once into a reused buffer (guarding against iRacing overwriting it mid-read), then decoded field-by-field via `ReadOnlySpan<T>` with no further allocations, keeping steady-state GC pressure near zero
- **Independent background tasks** mean CPU-intensive session-info YAML parsing never stalls the 60Hz telemetry path

Full details, including the threading model, buffering semantics, and built-in metrics: **[Architecture and Design](./docs/ARCHITECTURE.md)**
Full details on the threading model and buffering semantics: **[Architecture and Design](./docs/ARCHITECTURE.md)**. For the built-in metrics and how to collect them: **[Metrics and Diagnostics](./docs/METRICS.md)**

## Requirements

Expand Down Expand Up @@ -319,13 +321,15 @@ sim.Camera.SwitchToCar("001", cameraGroup: 1, camera: 1);
See [Samples Directory](./Samples/README.md) for ready-to-run example projects including:
- Basic telemetry monitoring
- IBT file analysis
- Collecting the SDK's built-in metrics
- Simulator control, including cameras, replay, pit service, and broadcast commands
- Data export utilities
- Track analysis tools

## Documentation

- **[Architecture and Design](./docs/ARCHITECTURE.md)** - Threading model, data streaming and buffering, performance characteristics, and built-in metrics
- **[Architecture and Design](./docs/ARCHITECTURE.md)** - Threading model, data streaming and buffering, and performance characteristics
- **[Metrics and Diagnostics](./docs/METRICS.md)** - Instrument reference, OpenTelemetry and DI setup, collection, and interpretation
- **[Advanced Usage](./docs/ADVANCED.md)** - Direct stream access, multiple consumers, and cancellation behavior
- **[Migration Guide](./docs/MIGRATION_GUIDE.md)** - Upgrading from early pre-1.0 releases

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.8" />
<PackageReference Include="SVappsLAB.iRacingTelemetrySDK" Version="2.4.0" />
<PackageReference Include="SVappsLAB.iRacingTelemetrySDK" Version="2.5.0" />
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion Samples/LocationAndWarnings/LocationAndWarnings.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.8" />
<PackageReference Include="SVappsLAB.iRacingTelemetrySDK" Version="2.4.0" />
<PackageReference Include="SVappsLAB.iRacingTelemetrySDK" Version="2.5.0" />
</ItemGroup>

</Project>
15 changes: 15 additions & 0 deletions Samples/Metrics/MeterListener/MeterListener.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.8" />
<PackageReference Include="SVappsLAB.iRacingTelemetrySDK" Version="2.5.0" />
</ItemGroup>

</Project>
133 changes: 133 additions & 0 deletions Samples/Metrics/MeterListener/MetricsCollector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Copyright (C) 2024-2026 Scott Velez
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

using SVappsLAB.iRacingTelemetrySDK;
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;

namespace MeterListenerSample;

/// <summary>
/// Collects the SDK's metrics with a <see cref="System.Diagnostics.Metrics.MeterListener"/>,
/// which is built into the runtime and needs no packages.
///
/// A MeterListener hands you raw measurements - one callback per recorded value - and stops
/// there, so everything below is the aggregation OpenTelemetry would otherwise do for you:
/// the dictionary keyed by instrument-plus-tags, the <see cref="Stat"/> rows, the reporting
/// timer, and the printing. It is all here rather than in Program.cs so the sample's client
/// setup reads the same as the OpenTelemetry sample's.
/// </summary>
internal sealed class MetricsCollector : IDisposable
{
// one row per instrument, per set of tags - so the drop reasons stay apart
readonly ConcurrentDictionary<string, Stat> _stats = new();
readonly object _consoleLock = new();
readonly MeterListener _listener;
readonly Timer _timer;

/// <summary>
/// Starts listening. Measurements are printed every <paramref name="reportInterval"/>,
/// and whenever <see cref="Print"/> is called.
/// </summary>
public MetricsCollector(TimeSpan reportInterval)
{
// subscribe to the SDK's meter by name; the SDK creates the meter itself, so
// nothing has to be passed to the client
_listener = new MeterListener
{
InstrumentPublished = (instrument, theListener) =>
{
if (instrument.Meter.Name == TelemetryMetrics.MeterName)
theListener.EnableMeasurementEvents(instrument);
}
};

// raw measurements arrive one at a time, and we aggregate them ourselves.
// the SDK's counters are long, its histograms are double
_listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) => Record(instrument, value, tags));
_listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) => Record(instrument, value, tags));
_listener.Start();

_timer = new Timer(_ => Print("metrics"), null, reportInterval, reportInterval);
}

public void Dispose()
{
_timer.Dispose();
_listener.Dispose();
}

void Record(Instrument instrument, double value, ReadOnlySpan<KeyValuePair<string, object?>> tags)
{
// the meter-level 'data_source' tag is not repeated here; these are the per-measurement
// tags - drop.reason, error.type, session_info.stream - and each combination gets its own row
var key = instrument.Name;
foreach (var tag in tags)
key += $" [{tag.Key}={tag.Value}]";

_stats.GetOrAdd(key, _ => new Stat(instrument.Unit, instrument is Counter<long>)).Add(value);
}

public void Print(string title)
{
lock (_consoleLock)
{
Console.WriteLine($"{Environment.NewLine}--- {title} ---");

foreach (var (name, stat) in _stats.OrderBy(kvp => kvp.Key))
{
var value = stat switch
{
// counters are totals
{ IsCounter: true } => $"{stat.Sum,10:N0} {stat.Unit}",
// durations read better scaled than in raw seconds
{ Unit: "s" } => $"{stat.Count,10:N0} recorded avg {Duration(stat.Sum / stat.Count)} max {Duration(stat.Max)}",
// everything else - byte sizes, parse attempts
_ => $"{stat.Count,10:N0} recorded avg {stat.Sum / stat.Count,10:N0} {stat.Unit} max {stat.Max,10:N0} {stat.Unit}"
};

// trim the meter prefix the instruments all share
Console.WriteLine($" {name.Replace("svappslab.iracingsdk.", ""),-62} {value}");
}
}
}

// most of these land in microseconds, so scale rather than print a row of zeros
static string Duration(double seconds) =>
seconds < 0.001 ? $"{seconds * 1_000_000,8:N1} us" : $"{seconds * 1_000,8:N3} ms";

// count/sum/max is all a MeterListener gives you without writing a bucketing histogram
// yourself - no percentiles, and the SDK's bucket 'advice' is not applied
class Stat(string? unit, bool isCounter)
{
public string? Unit => unit;
public bool IsCounter => isCounter;
public long Count { get; private set; }
public double Sum { get; private set; }
public double Max { get; private set; }

public void Add(double value)
{
// measurements arrive on the SDK's threads
lock (this)
{
Count++;
Sum += value;
Max = Math.Max(Max, value);
}
}
}
}
72 changes: 72 additions & 0 deletions Samples/Metrics/MeterListener/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Copyright (C) 2024-2026 Scott Velez
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

using Microsoft.Extensions.Logging;
using SVappsLAB.iRacingTelemetrySDK;

namespace MeterListenerSample;

[RequiredTelemetryVars([TelemetryVar.Speed, TelemetryVar.RPM])]
internal class Program
{
public static async Task Main(string[] args)
{
// === collection - the only part that differs from the OpenTelemetry sample ===

// MetricsCollector (MetricsCollector.cs) is the MeterListener half of this sample.
using var metrics = new MetricsCollector(reportInterval: TimeSpan.FromSeconds(5));

// === client setup - identical in both samples ===

var logger = LoggerFactory.Create(builder => builder.AddConsole())
.CreateLogger("MeterListener");

// no argument for live telemetry, an IBT filepath to replay a file. an optional second
// argument is the playback speed - '1' replays at the speed it was recorded, which is the
// only way playback.lag gets recorded. the default is max speed, which has no schedule
IBTOptions? ibtOptions = args.Length switch
{
0 => null,
1 => new IBTOptions(args[0]),
_ => new IBTOptions(args[0], int.Parse(args[1]))
};

var clientOptions = new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous };
await using var client = TelemetryClient<TelemetryData>.Create(logger, ibtOptions, clientOptions);

using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };

// synchronous delivery runs this handler inline, so its cost is the consumer's cost.
// give it some work - await Task.Delay(50) - and watch handler.duration climb;
// live telemetry will then report missed_tick as the read loop falls behind
var handlers = new TelemetryHandlers<TelemetryData>
{
OnTelemetryUpdate = _ =>
{
client.GetValue("Speed");
client.GetValue("RPM");
return Task.CompletedTask;
}
};

Console.WriteLine("press Ctrl-C to exit...");
await client.Monitor(handlers, cts.Token);

// one last report, for the run as a whole
metrics.Print("final metrics");
}
}
8 changes: 8 additions & 0 deletions Samples/Metrics/MeterListener/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"profiles": {
"MeterListener": {
"commandName": "Project",
"commandLineArgs": "\"..\\..\\..\\..\\..\\data\\formulair04_tsukuba 2kfull 2024-01-09 17-26-10.ibt\""
}
}
}
23 changes: 23 additions & 0 deletions Samples/Metrics/OpenTelemetry/OpenTelemetry.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- the project is named OpenTelemetry, same as the package it references.
add Sample suffix here to resolve.
-->
<PackageId>OpenTelemetrySample</PackageId>
<AssemblyName>OpenTelemetrySample</AssemblyName>
<RootNamespace>OpenTelemetrySample</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.8" />
<PackageReference Include="OpenTelemetry" Version="1.19.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.19.0" />
<PackageReference Include="SVappsLAB.iRacingTelemetrySDK" Version="2.5.0" />
</ItemGroup>

</Project>
Loading
Loading