Skip to content
45 changes: 44 additions & 1 deletion docs/configuration/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Xping SDK supports multiple configuration methods with the following priority or
| `UploadTimeout` | TimeSpan | `30s` | `XPING_UPLOADTIMEOUT` | HTTP request timeout |
| `CollectNetworkMetrics` | bool | `true` | `XPING_COLLECTNETWORKMETRICS` | Network metrics collection |
| `EnablePullRequestDetection` | bool | `true` | `XPING_ENABLEPULLREQUESTDETECTION` | Detect PR context for CI/CD comment posting |
| `CollectLocalGitAuthor` | bool | `false` | `XPING_COLLECTLOCALGITAUTHOR` | Include git author name in local-run metadata (opt-in to avoid PII collection) |
| `StrictMode` | bool | `false` | `XPING_STRICTMODE` | Throw on configuration errors instead of silently disabling |

---
Expand Down Expand Up @@ -671,6 +672,47 @@ XpingContext.Initialize(config);

---

### CollectLocalGitAuthor

**Type:** `bool`
**Default:** `false`
**Environment Variable:** `XPING_COLLECTLOCALGITAUTHOR`

When running on a developer machine (not a CI environment) inside a git repository, controls whether the SDK reads the author name from `.git/config [user] name` and includes it as the `Git.Actor` custom property in environment metadata.

This setting is **disabled by default** because `user.name` in `.git/config` is typically a developer's real full name — collecting and uploading it without explicit consent is a PII concern. Enable it only when your team is aware and has agreed to share this information.

**When enabled, the following custom property is populated:**
- `Git.Actor` — value of `[user] name` from the local `.git/config`

**This setting has no effect when:**
- Running in a CI environment (`IsCIEnvironment = true`) — CI actor comes from the CI provider's environment variables instead
- Running outside a git repository

**Example:**

```json
{
"Xping": {
"CollectLocalGitAuthor": true
}
}
```

```bash
export XPING_COLLECTLOCALGITAUTHOR="true"
```

```csharp
var config = new XpingConfiguration
{
CollectLocalGitAuthor = true
};
XpingContext.Initialize(config);
```

---

## Advanced Settings

> **Logging:** The SDK uses `Microsoft.Extensions.Logging.ILogger` for diagnostics. Configure log verbosity through your host's standard logging configuration (e.g., `appsettings.json` `Logging` section or `ILoggingBuilder`). There are no SDK-specific `LogLevel` or `Logger` configuration properties.
Expand Down Expand Up @@ -837,7 +879,8 @@ export XPING_SAMPLINGRATE="0.1"
"SamplingRate": 1.0,
"UploadTimeout": "00:00:30",
"CollectNetworkMetrics": true,
"EnablePullRequestDetection": true
"EnablePullRequestDetection": true,
"CollectLocalGitAuthor": false
}
}
```
Expand Down
8 changes: 8 additions & 0 deletions src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ public string Environment
/// </summary>
public bool EnablePullRequestDetection { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether to include the local git author name
/// (read from <c>.git/config [user] name</c>) in environment metadata when running
/// outside a CI environment.
/// Disabled by default to prevent unintentional collection of developer PII.
/// </summary>
Comment thread
xping-admin marked this conversation as resolved.
public bool CollectLocalGitAuthor { get; set; }

/// <summary>
/// Gets or sets a value indicating whether strict mode is enabled.
/// When <see langword="true"/>, configuration errors cause the SDK to throw a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,11 @@ private static void BindEnvironmentVariablesWithPrefix(XpingConfiguration config
&& bool.TryParse(enablePr, out var pr))
config.EnablePullRequestDetection = pr;

// Local Git Options
if (GetEnv("COLLECTLOCALGITAUTHOR") is { } collectGitAuthor
&& bool.TryParse(collectGitAuthor, out var cga))
config.CollectLocalGitAuthor = cga;

// Strict Mode Options
if (GetEnv("STRICTMODE") is { } strictMode
&& bool.TryParse(strictMode, out var sm))
Expand Down Expand Up @@ -574,6 +579,7 @@ private static void CopyConfiguration(XpingConfiguration source, XpingConfigurat
target.UploadTimeout = source.UploadTimeout;
target.CollectNetworkMetrics = source.CollectNetworkMetrics;
target.EnablePullRequestDetection = source.EnablePullRequestDetection;
target.CollectLocalGitAuthor = source.CollectLocalGitAuthor;
target.StrictMode = source.StrictMode;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,16 +415,29 @@ private bool DetectIsContainer()

private Dictionary<string, string> CollectCustomProperties(string operatingSystem, CIPlatform? ciPlatform, bool isContainer)
{
Dictionary<string, string> properties = new Dictionary<string, string>();
Dictionary<string, string> properties = new()
{
["ExecutionContext"] = ciPlatform.HasValue
? XpingConfiguration.DefaultCiEnvironment
: XpingConfiguration.DefaultEnvironment
};

properties["ExecutionContext"] = ciPlatform.HasValue
? XpingConfiguration.DefaultCiEnvironment
: XpingConfiguration.DefaultEnvironment;
if (!ciPlatform.HasValue && !isContainer)
{
properties["IsDeveloperMachine"] = "true";
}

// Detect whether running inside a git repository
string? gitDir = FindGitDirectory();
bool isInsideGitRepository = gitDir is not null;
properties["IsInsideGitRepository"] = isInsideGitRepository ? "true" : "false";

// Collect local git metadata when not running in a CI environment
if (!ciPlatform.HasValue && gitDir is not null)
{
CollectLocalGitMetadata(properties, gitDir, _configuration.CollectLocalGitAuthor);
}

// Add container information
if (isContainer)
{
Expand Down Expand Up @@ -572,6 +585,247 @@ private Dictionary<string, string> CollectCustomProperties(string operatingSyste
return properties;
}

private static string? FindGitDirectory()
{
try
{
var dir = new DirectoryInfo(System.Environment.CurrentDirectory);
while (dir is not null)
{
string candidate = Path.Combine(dir.FullName, ".git");

if (Directory.Exists(candidate))
return candidate;

// Worktrees and submodules: .git is a file containing "gitdir: <path>"
if (File.Exists(candidate))
{
string content = File.ReadAllText(candidate).Trim();
const string gitdirPrefix = "gitdir: ";
if (content.StartsWith(gitdirPrefix, StringComparison.Ordinal))
{
string gitdirPath = content.Substring(gitdirPrefix.Length).Trim();
if (!Path.IsPathRooted(gitdirPath))
gitdirPath = Path.GetFullPath(Path.Combine(dir.FullName, gitdirPath));
if (Directory.Exists(gitdirPath))
return gitdirPath;
}
}

dir = dir.Parent;
Comment thread
xping-admin marked this conversation as resolved.
}
return null;
}
catch
{
return null;
}
}

private static void CollectLocalGitMetadata(Dictionary<string, string> properties, string gitDir, bool includeAuthor)
{
try
{
string headPath = Path.Combine(gitDir, "HEAD");
if (!File.Exists(headPath))
return;

string headContent = File.ReadAllText(headPath).Trim();
const string headsRefPrefix = "ref: refs/heads/";
const string refAnnotation = "ref: ";
bool isDetachedHead;
string? branch;
string? sha;

if (headContent.StartsWith(headsRefPrefix, StringComparison.Ordinal))
{
// Normal branch checkout
isDetachedHead = false;
branch = headContent.Substring(headsRefPrefix.Length);
sha = ResolveCommitSha(gitDir, branch);
}
else if (headContent.StartsWith(refAnnotation, StringComparison.Ordinal))
{
// Symbolic ref to a non-branch (tag, remote, etc.) — not detached HEAD,
// but we cannot safely resolve a branch name or SHA without following the full ref chain.
isDetachedHead = false;
branch = null;
sha = null;
}
else if (IsValidSha(headContent))
{
// Raw SHA — truly detached HEAD
isDetachedHead = true;
branch = null;
sha = headContent;
}
else
{
// Unrecognized HEAD format — skip metadata collection
return;
}
Comment thread
xping-admin marked this conversation as resolved.

if (isDetachedHead)
{
properties["IsDetachedHead"] = "true";
}

if (branch is not null)
{
AddIfNotNull(properties, "Git.Branch", branch);
}

AddIfNotNull(properties, "Git.SHA", sha);

if (includeAuthor)
{
string? authorName = ReadGitConfigUserName(gitDir);
AddIfNotNull(properties, "Git.Actor", authorName);
}

bool? hasStagedChanges = DetectStagedChanges(gitDir, branch);
if (hasStagedChanges.HasValue)
{
properties["HasStagedChanges"] = hasStagedChanges.Value ? "true" : "false";
}
Comment thread
xping-admin marked this conversation as resolved.
}
catch
{
// Never throw from environment detection methods
}
}

private static string? ResolveCommitSha(string gitDir, string branch)
{
try
{
string refFilePath = Path.Combine(gitDir, "refs", "heads", branch);
if (File.Exists(refFilePath))
{
return File.ReadAllText(refFilePath).Trim();
}

// Fall back to packed-refs
string packedRefsPath = Path.Combine(gitDir, "packed-refs");
if (File.Exists(packedRefsPath))
{
string target = $"refs/heads/{branch}";
foreach (string line in File.ReadAllLines(packedRefsPath))
{
if (line.EndsWith(target, StringComparison.Ordinal))
{
int spaceIndex = line.IndexOf(' ');
if (spaceIndex > 0)
{
return line.Substring(0, spaceIndex);
}
}
}
}

return null;
}
catch
{
return null;
}
}

private static string? ReadGitConfigUserName(string gitDir)
{
try
{
string configPath = Path.Combine(gitDir, "config");
if (!File.Exists(configPath))
return null;

bool inUserSection = false;
foreach (string line in File.ReadAllLines(configPath))
{
string trimmed = line.Trim();

if (trimmed == "[user]")
{
inUserSection = true;
continue;
}

if (trimmed.StartsWith("[", StringComparison.Ordinal) && inUserSection)
{
break;
}

if (inUserSection)
{
int eqIndex = trimmed.IndexOf('=');
if (eqIndex < 0)
{
continue;
}

string key = trimmed.Substring(0, eqIndex).Trim();
if (string.Equals(key, "name", StringComparison.OrdinalIgnoreCase))
{
return trimmed.Substring(eqIndex + 1).Trim();
}
}
}

return null;
}
catch
{
return null;
}
}

// Heuristic: compares the mtime of .git/index (updated on git add) against the mtime of
// the last-commit ref file. Returns true if the index appears newer than the last commit,
// suggesting staged-but-not-yet-committed changes. Does NOT detect unstaged working-tree
// edits. Property key is "HasStagedChanges" to reflect this limitation accurately.
private static bool? DetectStagedChanges(string gitDir, string? branch)
Comment thread
xping-admin marked this conversation as resolved.
{
try
{
string indexFile = Path.Combine(gitDir, "index");
if (!File.Exists(indexFile))
return null;

DateTime indexModified = File.GetLastWriteTimeUtc(indexFile);

// Try loose ref first
if (branch is not null)
{
string loosePath = Path.Combine(gitDir, "refs", "heads", branch);
if (File.Exists(loosePath))
return indexModified > File.GetLastWriteTimeUtc(loosePath);
}

// Fall back to packed-refs mtime as a conservative proxy
string packedRefsPath = Path.Combine(gitDir, "packed-refs");
if (File.Exists(packedRefsPath))
return indexModified > File.GetLastWriteTimeUtc(packedRefsPath);

return null;
}
catch
{
return null;
}
}

private static bool IsValidSha(string value)
{
if (value.Length is not 40 and not 64)
return false;
foreach (char c in value)
{
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')))
return false;
}
return true;
}

private static void AddIfNotNull(Dictionary<string, string> dictionary, string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
Expand Down
Loading
Loading