Artifacts

Symbol Server Setup with Azure Artifacts

Configure Azure Artifacts as a symbol server for debugging production issues with full source linking and PDB indexing

Symbol Server Setup with Azure Artifacts

Overview

A symbol server stores debugging symbols (PDB files) separately from your compiled binaries, letting developers attach a debugger to a running process and step through source code even in production. Azure Artifacts provides a built-in symbol server that integrates directly with your Azure DevOps CI/CD pipelines, eliminating the need to maintain a separate file share or third-party symbol infrastructure. If you have ever stared at a stack trace full of [External Code] frames and wished you could see exactly what line of your library threw an exception, a symbol server is the piece you are missing.

Prerequisites

  • An Azure DevOps organization with an active project
  • An Azure Artifacts feed (Universal or NuGet)
  • Visual Studio 2019 or later (Community edition works)
  • .NET SDK 6.0 or later installed
  • Basic familiarity with Azure Pipelines YAML
  • A NuGet package you want to publish (or willingness to create one)
  • Node.js 18+ (for the automation scripts in this article)

What Symbol Servers Do

When you compile a .NET project in Debug or Release configuration, the compiler produces two things: the assembly DLL (or EXE) and a PDB file. The PDB — Program Database — contains the mapping between compiled IL instructions and your original source code. Line numbers, local variable names, method boundaries — all of it lives in the PDB.

Without the PDB, a debugger can show you disassembly and raw memory. With the PDB, it can show you your actual C# or F# source, highlight the exact line, and let you inspect variables by name.

A symbol server is simply an indexed storage location for PDB files. The debugger queries the symbol server using a unique hash embedded in both the DLL and its corresponding PDB. This hash ensures that you always get the exact PDB that matches your binary — not a stale version from a different build.

The flow works like this:

  1. Your CI pipeline compiles the code and produces DLLs and PDBs.
  2. The pipeline publishes the PDBs to the symbol server, indexed by their unique hash.
  3. A developer (or a production diagnostic tool) attaches a debugger to the running process.
  4. The debugger reads the hash from the loaded DLL and queries the symbol server.
  5. The symbol server returns the matching PDB.
  6. The debugger maps IL offsets to source lines and enables full debugging.

This is not a new concept. Microsoft has run the public symbol server at https://msdl.microsoft.com/download/symbols for decades. What Azure Artifacts does is give you this same capability for your own internal libraries.

Azure Artifacts as a Symbol Server

Azure Artifacts feeds have built-in symbol server support. You do not need to provision a separate service or configure a file share. Every Azure Artifacts feed can serve symbols out of the box, though you do need to enable the feature.

Enabling the Symbol Server on Your Feed

Navigate to your Azure DevOps project, go to Artifacts, select your feed, and click the gear icon for feed settings. Under the Symbol server section, toggle it on. You will see a symbol server URL that looks like this:

https://pkgs.dev.azure.com/{organization}/{project}/_packaging/{feed}/symsrv/

This URL is what you will configure in Visual Studio or any other debugger that supports the symsrv protocol.

Authentication

The symbol server uses the same authentication as the rest of Azure Artifacts. For Visual Studio, this typically means your Azure DevOps credentials flow through automatically if you are signed in. For CI/CD scenarios or scripted access, you will use a Personal Access Token (PAT) with the Packaging (Read) scope.

Publishing Symbols from CI/CD Pipelines

The most reliable way to publish symbols is from your build pipeline. Azure Pipelines has a dedicated task for this: PublishSymbols@2.

Basic YAML Pipeline with Symbol Publishing

Here is a complete pipeline that builds a .NET library, packs it as a NuGet package, publishes the package to an Azure Artifacts feed, and publishes the symbols alongside it:

trigger:
  branches:
    include:
      - main

pool:
  vmImage: 'windows-latest'

variables:
  buildConfiguration: 'Release'
  feedName: 'internal-packages'

steps:
  - task: UseDotNet@2
    displayName: 'Install .NET SDK'
    inputs:
      packageType: 'sdk'
      version: '8.x'

  - task: DotNetCoreCLI@2
    displayName: 'Restore packages'
    inputs:
      command: 'restore'
      projects: '**/*.csproj'

  - task: DotNetCoreCLI@2
    displayName: 'Build solution'
    inputs:
      command: 'build'
      projects: '**/*.csproj'
      arguments: '--configuration $(buildConfiguration) --no-restore'

  - task: DotNetCoreCLI@2
    displayName: 'Run tests'
    inputs:
      command: 'test'
      projects: '**/*Tests.csproj'
      arguments: '--configuration $(buildConfiguration) --no-build'

  - task: DotNetCoreCLI@2
    displayName: 'Pack NuGet package'
    inputs:
      command: 'pack'
      packagesToPack: '**/MyLibrary.csproj'
      configuration: '$(buildConfiguration)'
      versioningScheme: 'byBuildNumber'

  - task: NuGetAuthenticate@1
    displayName: 'Authenticate NuGet feed'

  - task: NuGetCommand@2
    displayName: 'Push NuGet package'
    inputs:
      command: 'push'
      packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg'
      nuGetFeedType: 'internal'
      publishVstsFeed: '$(feedName)'

  - task: PublishSymbols@2
    displayName: 'Publish symbols to Azure Artifacts'
    inputs:
      SearchPattern: '**/bin/$(buildConfiguration)/**/*.pdb'
      SymbolServerType: 'TeamServices'
      DetailedLog: true

The critical piece is PublishSymbols@2. The SymbolServerType: 'TeamServices' setting tells the task to push symbols to your Azure DevOps organization's symbol server rather than to an on-premises file share. The SearchPattern finds all PDB files produced by the build.

Including Symbols Inside NuGet Packages

An alternative (or complementary) approach is to include symbols directly in your NuGet package as a .snupkg file. This ensures consumers always have symbols available without configuring a separate symbol server:

<!-- In your .csproj file -->
<PropertyGroup>
  <IncludeSymbols>true</IncludeSymbols>
  <SymbolPackageFormat>snupkg</SymbolPackageFormat>
  <EmbedAllSources>true</EmbedAllSources>
</PropertyGroup>

The .snupkg file is published alongside your .nupkg. Azure Artifacts automatically extracts and indexes the symbols from the .snupkg.

Configuring Visual Studio for Symbol Consumption

Once symbols are published, developers need to configure their debugger to pull from the symbol server.

Visual Studio Setup

  1. Open Tools > Options > Debugging > Symbols.
  2. Click the + icon to add a new symbol server location.
  3. Enter your Azure Artifacts symbol server URL:
https://pkgs.dev.azure.com/{organization}/{project}/_packaging/{feed}/symsrv/
  1. Set a local cache directory (e.g., C:\SymbolCache). This prevents re-downloading the same PDBs repeatedly.
  2. Under Load only specified modules, you can limit symbol loading to your own assemblies to avoid slow startup.

Recommended Visual Studio Settings

Disable automatic loading of symbols for all modules. This dramatically speeds up debugging:

Tools > Options > Debugging > Symbols:
  - Check "Load only specified modules"
  - Add your assembly names: MyLibrary.dll, MyCompany.Core.dll, etc.

Tools > Options > Debugging > General:
  - Uncheck "Enable .NET Framework source stepping" (unless you need it)
  - Check "Enable source server support"
  - Check "Enable Source Link support"

VS Code Setup

For VS Code with the C# extension (OmniSharp or C# Dev Kit), configure your launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Attach to Process",
      "type": "coreclr",
      "request": "attach",
      "processId": "${command:pickProcess}",
      "symbolOptions": {
        "searchPaths": [
          "https://pkgs.dev.azure.com/{organization}/{project}/_packaging/{feed}/symsrv/"
        ],
        "cachePath": "${workspaceFolder}/.symbols",
        "moduleFilter": {
          "mode": "loadOnlyIncluded",
          "includedModules": [
            "MyLibrary.dll",
            "MyCompany.Core.dll"
          ]
        }
      },
      "sourceLinkOptions": {
        "*": { "enabled": true }
      }
    }
  ]
}

The symbolOptions.searchPaths array accepts symbol server URLs. The cachePath keeps downloaded PDBs local so you do not hit the network on every debug session.

Indexing PDB Files

Symbol indexing is the process that makes PDB files discoverable by their unique signature. Without indexing, a symbol server is just a folder full of files. With indexing, the debugger can request a specific PDB by its GUID and age, and the server returns the exact match.

How Indexing Works

Every PDB file contains a GUID and an "age" counter. The corresponding DLL embeds the same GUID and age in its PE header. When the debugger loads a DLL, it reads this signature and constructs a request to the symbol server:

GET /MyLibrary.pdb/{GUID}{AGE}/MyLibrary.pdb

The symbol server maintains an index mapping these signatures to the actual PDB files on disk.

Verifying Indexed Symbols

You can verify that your symbols were indexed correctly using the symchk tool from the Windows Debugging Tools:

symchk /r "C:\path\to\MyLibrary.dll" /s "SRV*C:\SymbolCache*https://pkgs.dev.azure.com/{org}/{project}/_packaging/{feed}/symsrv/"

Expected output when symbols are found:

SYMCHK: MyLibrary.dll           PASSED
SYMCHK: PASSED + IGNORED files = 1
SYMCHK: FAILED files = 0

If symbols are missing:

SYMCHK: MyLibrary.dll           FAILED  - MyLibrary.pdb mismatched or not found
SYMCHK: PASSED + IGNORED files = 0
SYMCHK: FAILED files = 1

Querying Symbol Status via the Azure DevOps REST API

You can also check symbol publication status programmatically. Here is a Node.js script that queries the Azure DevOps API to verify a build's symbols were published:

var https = require("https");
var url = require("url");

var organization = "myorg";
var project = "myproject";
var buildId = process.argv[2] || "12345";
var pat = process.env.AZURE_DEVOPS_PAT;

function checkSymbolStatus(buildId, callback) {
  var token = Buffer.from(":" + pat).toString("base64");
  var apiUrl = "https://dev.azure.com/" + organization + "/" + project +
    "/_apis/build/builds/" + buildId + "/artifacts?api-version=7.1";

  var parsed = url.parse(apiUrl);
  var options = {
    hostname: parsed.hostname,
    path: parsed.path,
    method: "GET",
    headers: {
      "Authorization": "Basic " + token,
      "Content-Type": "application/json"
    }
  };

  var req = https.request(options, function(res) {
    var body = "";
    res.on("data", function(chunk) { body += chunk; });
    res.on("end", function() {
      var result = JSON.parse(body);
      var symbolArtifacts = result.value.filter(function(artifact) {
        return artifact.name.indexOf("Symbols_") === 0;
      });

      if (symbolArtifacts.length > 0) {
        console.log("Symbols published successfully for build " + buildId);
        symbolArtifacts.forEach(function(artifact) {
          console.log("  Artifact: " + artifact.name);
          console.log("  Resource: " + artifact.resource.url);
        });
      } else {
        console.log("WARNING: No symbol artifacts found for build " + buildId);
      }
      callback(null, symbolArtifacts);
    });
  });

  req.on("error", function(err) {
    callback(err);
  });

  req.end();
}

checkSymbolStatus(buildId, function(err, artifacts) {
  if (err) {
    console.error("Error checking symbols:", err.message);
    process.exit(1);
  }
});

Run it like this:

AZURE_DEVOPS_PAT=your_pat_here node check-symbols.js 12345

Expected output:

Symbols published successfully for build 12345
  Artifact: Symbols_MyLibrary_20260213.1
  Resource: https://dev.azure.com/myorg/myproject/_apis/build/builds/12345/artifacts?artifactName=Symbols_MyLibrary_20260213.1

Source Linking for Debugging Production Issues

Symbols alone tell you which line of code is executing, but without the actual source file, you still cannot see the code. Source Link solves this by embedding a URL mapping inside the PDB that points to the exact commit in your source control.

Enabling Source Link

Add the appropriate Source Link package to your project:

<!-- For Azure Repos (Azure DevOps Git) -->
<ItemGroup>
  <PackageReference Include="Microsoft.SourceLink.AzureRepos.Git" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>

<!-- For GitHub -->
<ItemGroup>
  <PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>

And ensure these properties are set in your .csproj:

<PropertyGroup>
  <PublishRepositoryUrl>true</PublishRepositoryUrl>
  <EmbedUntrackedSources>true</EmbedUntrackedSources>
  <DebugType>portable</DebugType>
  <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>

The ContinuousIntegrationBuild flag is important. It makes builds deterministic by normalizing file paths in the PDB. Without it, the PDB contains your CI agent's local file paths (e.g., D:\a\1\s\src\MyLibrary\Service.cs), which are useless to anyone debugging on a different machine.

How Source Link Works During Debugging

When a developer hits a breakpoint in your library code:

  1. The debugger loads the PDB from the symbol server.
  2. It reads the Source Link mapping from the PDB — a JSON document like this:
{
  "documents": {
    "C:\\src\\*": "https://dev.azure.com/myorg/myproject/_apis/git/repositories/myrepo/items?api-version=1.0&versionType=commit&version=abc123def456&path=/*"
  }
}
  1. The debugger constructs the URL for the specific source file and downloads it.
  2. The developer sees the actual source code at the exact commit that produced the binary.

This is incredibly powerful for debugging production issues. You do not need the source code checked out locally. You do not need to guess which version of the code is running. The PDB knows exactly which commit produced it.

Verifying Source Link in a PDB

Use the sourcelink dotnet tool to verify your PDB contains valid Source Link information:

dotnet tool install --global sourcelink
sourcelink print-urls MyLibrary.pdb

Expected output:

7d3a4f2e... src/MyLibrary/Service.cs
  https://dev.azure.com/myorg/myproject/_apis/git/repositories/myrepo/items?api-version=1.0&versionType=commit&version=abc123def&path=/src/MyLibrary/Service.cs

b18c9012... src/MyLibrary/Repository.cs
  https://dev.azure.com/myorg/myproject/_apis/git/repositories/myrepo/items?api-version=1.0&versionType=commit&version=abc123def&path=/src/MyLibrary/Repository.cs

If the URLs return 404s, your Source Link configuration is wrong — usually because the repository URL does not match what is in Source Link's mapping.

Symbol Server for NuGet Packages

When you publish a NuGet package with symbols to Azure Artifacts, the symbol server and the package feed work together. Consumers install your NuGet package normally, and when they debug into your code, the debugger automatically fetches symbols from the same feed.

Complete .csproj for a Symbol-Ready NuGet Package

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <PackageId>MyCompany.SharedLibrary</PackageId>
    <Version>2.1.0</Version>
    <Authors>Shane Larson</Authors>
    <Description>Shared utilities for internal services</Description>

    <!-- Symbol package configuration -->
    <IncludeSymbols>true</IncludeSymbols>
    <SymbolPackageFormat>snupkg</SymbolPackageFormat>

    <!-- Source Link configuration -->
    <PublishRepositoryUrl>true</PublishRepositoryUrl>
    <EmbedUntrackedSources>true</EmbedUntrackedSources>
    <DebugType>portable</DebugType>
    <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.SourceLink.AzureRepos.Git" Version="8.0.0" PrivateAssets="All" />
  </ItemGroup>
</Project>

Notice the conditional ContinuousIntegrationBuild. You only want deterministic paths in CI builds. During local development, you want your real paths so that the debugger can find your local source files directly.

Pipeline YAML for NuGet + Symbols

steps:
  - task: DotNetCoreCLI@2
    displayName: 'Pack with symbols'
    inputs:
      command: 'pack'
      packagesToPack: '**/MyCompany.SharedLibrary.csproj'
      configuration: 'Release'
      arguments: '/p:CI=true'

  - task: NuGetAuthenticate@1
    displayName: 'Authenticate to feed'

  - task: DotNetCoreCLI@2
    displayName: 'Push package and symbols'
    inputs:
      command: 'push'
      packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg'
      nuGetFeedType: 'internal'
      publishVstsFeed: 'internal-packages'

  - task: PublishSymbols@2
    displayName: 'Index and publish symbols'
    inputs:
      SearchPattern: '**/bin/Release/**/*.pdb'
      SymbolServerType: 'TeamServices'
      IndexSources: true
      PublishSymbols: true
      DetailedLog: true

The IndexSources: true flag tells the task to embed source indexing information into the PDB before publishing. This is the legacy equivalent of Source Link and works with older tooling that does not support Source Link natively.

Cross-Platform Symbol Support

Historically, PDB files were a Windows-only format. The "classic" PDB format (also called Windows PDB) is a proprietary Microsoft format that only works with Windows debugging tools.

Modern .NET uses Portable PDB, a cross-platform, open format that works on Windows, Linux, and macOS. If you are targeting .NET Core / .NET 5+, you are already using Portable PDBs by default.

Ensuring Portable PDB Generation

Set this in your .csproj to be explicit:

<PropertyGroup>
  <DebugType>portable</DebugType>
</PropertyGroup>

Possible values:

Value Format Platform
portable Portable PDB Cross-platform
embedded Portable PDB (inside DLL) Cross-platform
full Windows PDB Windows only
pdbonly Windows PDB (no debug info in DLL) Windows only

For most modern projects, use portable. Use embedded if you want to distribute a single DLL with symbols included — useful for open-source packages where you cannot guarantee consumers have access to your symbol server.

Debugging .NET on Linux with Azure Artifacts Symbols

On Linux, you can use dotnet-dump, dotnet-trace, or lldb with the SOS debugging extension:

# Install diagnostic tools
dotnet tool install --global dotnet-symbol
dotnet tool install --global dotnet-dump

# Download symbols for a core dump
dotnet-symbol --symbols --output /tmp/symbols /tmp/coredump

# Or configure the symbol server directly
export DOTNET_SYMBOL_SERVER="https://pkgs.dev.azure.com/{org}/{project}/_packaging/{feed}/symsrv/"
dotnet-dump analyze /tmp/coredump

For authenticated access to Azure Artifacts on Linux, you will need to set up a credential provider:

# Install the Azure Artifacts credential provider
wget -qO- https://aka.ms/install-artifacts-credprovider.sh | bash

# Set environment variable for non-interactive auth
export VSS_NUGET_EXTERNAL_FEED_ENDPOINTS='{"endpointCredentials":[{"endpoint":"https://pkgs.dev.azure.com/{org}/_packaging/{feed}/nuget/v3/index.json","username":"","password":"YOUR_PAT"}]}'

Complete Working Example: End-to-End Symbol Publishing Pipeline

Here is a full, production-ready Azure DevOps pipeline that builds a .NET solution, runs tests, publishes NuGet packages with symbols, and verifies the symbol publication:

trigger:
  branches:
    include:
      - main
      - release/*

pr:
  branches:
    include:
      - main

pool:
  vmImage: 'windows-latest'

variables:
  buildConfiguration: 'Release'
  feedName: 'internal-packages'
  majorVersion: 2
  minorVersion: 1

name: '$(majorVersion).$(minorVersion).$(Rev:r)'

stages:
  - stage: Build
    displayName: 'Build and Test'
    jobs:
      - job: BuildJob
        displayName: 'Build, Test, Publish'
        steps:
          - task: UseDotNet@2
            displayName: 'Install .NET 8 SDK'
            inputs:
              packageType: 'sdk'
              version: '8.x'

          - task: DotNetCoreCLI@2
            displayName: 'Restore NuGet packages'
            inputs:
              command: 'restore'
              projects: '**/*.csproj'
              feedsToUse: 'select'
              vstsFeed: '$(feedName)'

          - task: DotNetCoreCLI@2
            displayName: 'Build solution'
            inputs:
              command: 'build'
              projects: '**/*.csproj'
              arguments: >-
                --configuration $(buildConfiguration)
                --no-restore
                /p:Version=$(Build.BuildNumber)
                /p:CI=true

          - task: DotNetCoreCLI@2
            displayName: 'Run unit tests'
            inputs:
              command: 'test'
              projects: '**/*Tests.csproj'
              arguments: '--configuration $(buildConfiguration) --no-build --collect:"XPlat Code Coverage"'

          - task: DotNetCoreCLI@2
            displayName: 'Pack NuGet with symbols'
            inputs:
              command: 'pack'
              packagesToPack: |
                **/MyCompany.SharedLibrary.csproj
                **/MyCompany.DataAccess.csproj
                **/MyCompany.Logging.csproj
              configuration: '$(buildConfiguration)'
              nobuild: true
              versioningScheme: 'byBuildNumber'
              arguments: '/p:CI=true'

          - task: NuGetAuthenticate@1
            displayName: 'Authenticate feed'

          - task: DotNetCoreCLI@2
            displayName: 'Push NuGet packages'
            inputs:
              command: 'push'
              packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg'
              nuGetFeedType: 'internal'
              publishVstsFeed: '$(feedName)'

          - task: PublishSymbols@2
            displayName: 'Publish symbols to Azure Artifacts'
            inputs:
              SearchPattern: '**/bin/$(buildConfiguration)/**/*.pdb'
              SymbolServerType: 'TeamServices'
              IndexSources: true
              PublishSymbols: true
              SymbolsMaximumWaitTime: 10
              DetailedLog: true
              SymbolExpirationInDays: 365

          - task: PublishBuildArtifacts@1
            displayName: 'Publish build artifacts'
            inputs:
              PathtoPublish: '$(Build.ArtifactStagingDirectory)'
              ArtifactName: 'packages'

          - powershell: |
              Write-Host "##[section]Symbol Publication Summary"
              Write-Host "Build Number: $(Build.BuildNumber)"
              Write-Host "Symbol Server: https://pkgs.dev.azure.com/$(System.CollectionUri)$(System.TeamProject)/_packaging/$(feedName)/symsrv/"
              Write-Host "PDB files published:"
              Get-ChildItem -Recurse -Filter "*.pdb" -Path "$(Build.SourcesDirectory)" |
                Where-Object { $_.FullName -match "bin\\$(buildConfiguration)" } |
                ForEach-Object { Write-Host "  $($_.Name) - $($_.Length) bytes" }
            displayName: 'Print symbol summary'

Automating Symbol Verification with Node.js

After the pipeline runs, use this Node.js script to verify symbols are accessible from the symbol server. This can run as a post-deployment health check:

var https = require("https");
var fs = require("fs");
var path = require("path");
var child_process = require("child_process");

var config = {
  organization: process.env.AZURE_ORG || "myorg",
  project: process.env.AZURE_PROJECT || "myproject",
  feed: process.env.AZURE_FEED || "internal-packages",
  pat: process.env.AZURE_DEVOPS_PAT,
  buildId: process.argv[2]
};

function makeRequest(url, callback) {
  var token = Buffer.from(":" + config.pat).toString("base64");
  var parsed = new URL(url);

  var options = {
    hostname: parsed.hostname,
    path: parsed.pathname + parsed.search,
    method: "GET",
    headers: {
      "Authorization": "Basic " + token,
      "Accept": "application/json"
    }
  };

  var req = https.request(options, function(res) {
    var body = "";
    res.on("data", function(chunk) { body += chunk; });
    res.on("end", function() {
      if (res.statusCode >= 400) {
        callback(new Error("HTTP " + res.statusCode + ": " + body));
        return;
      }
      callback(null, JSON.parse(body));
    });
  });

  req.on("error", callback);
  req.end();
}

function getBuildArtifacts(buildId, callback) {
  var url = "https://dev.azure.com/" + config.organization + "/" +
    config.project + "/_apis/build/builds/" + buildId +
    "/artifacts?api-version=7.1";
  makeRequest(url, callback);
}

function verifySymbolPublication(buildId) {
  console.log("Verifying symbol publication for build " + buildId + "...\n");

  getBuildArtifacts(buildId, function(err, data) {
    if (err) {
      console.error("Failed to fetch build artifacts:", err.message);
      process.exit(1);
    }

    var symbolArtifacts = data.value.filter(function(a) {
      return a.name.indexOf("Symbols_") === 0;
    });

    var packageArtifacts = data.value.filter(function(a) {
      return a.name === "packages";
    });

    console.log("Build Artifacts Summary:");
    console.log("  Total artifacts: " + data.value.length);
    console.log("  Symbol artifacts: " + symbolArtifacts.length);
    console.log("  Package artifacts: " + packageArtifacts.length);
    console.log("");

    if (symbolArtifacts.length === 0) {
      console.error("FAIL: No symbol artifacts found.");
      console.error("Check that PublishSymbols@2 task ran successfully.");
      process.exit(1);
    }

    symbolArtifacts.forEach(function(artifact) {
      console.log("PASS: " + artifact.name);
      console.log("  Type: " + artifact.resource.type);
      console.log("  URL: " + artifact.resource.url);
    });

    console.log("\nSymbol server URL for debugging:");
    console.log("  https://pkgs.dev.azure.com/" + config.organization +
      "/" + config.project + "/_packaging/" + config.feed + "/symsrv/");
    console.log("\nAll checks passed.");
  });
}

if (!config.pat) {
  console.error("Error: AZURE_DEVOPS_PAT environment variable is required.");
  process.exit(1);
}

if (!config.buildId) {
  console.error("Usage: node verify-symbols.js <build-id>");
  process.exit(1);
}

verifySymbolPublication(config.buildId);

Running the script:

AZURE_DEVOPS_PAT=xxxxxxxxxxxx node verify-symbols.js 4521

Expected output:

Verifying symbol publication for build 4521...

Build Artifacts Summary:
  Total artifacts: 3
  Symbol artifacts: 1
  Package artifacts: 1

PASS: Symbols_MyCompany_20260213.1
  Type: SymbolStore
  URL: https://dev.azure.com/myorg/myproject/_apis/build/builds/4521/artifacts?artifactName=Symbols_MyCompany_20260213.1

Symbol server URL for debugging:
  https://pkgs.dev.azure.com/myorg/myproject/_packaging/internal-packages/symsrv/

All checks passed.

Common Issues and Troubleshooting

1. Symbols Not Found: GUID Mismatch

Error in Visual Studio:

A matching symbol file was not found in this folder.
MyLibrary.pdb: signature does not match. [ E92F3A1B-... != D481C7E3-... ]

Cause: The PDB was built from a different commit than the DLL you are debugging. This happens when you rebuild locally but deploy from CI, or when the PDB was published from a different branch.

Fix: Make sure you are publishing symbols from the same build that produces your deployment artifacts. Never rebuild between packaging and symbol publication — use --no-build on the pack step:

- task: DotNetCoreCLI@2
  inputs:
    command: 'pack'
    nobuild: true   # Use the DLLs from the build step, do not recompile

2. Source Link Returns 404

Error in Visual Studio:

Source Link: Could not download file
  'https://dev.azure.com/myorg/myproject/_apis/git/repositories/myrepo/items?path=/src/Service.cs&version=abc123'
  HTTP 404 Not Found

Cause: The commit hash embedded in the PDB no longer exists in the repository. This can happen if history was rewritten (force push) or if the repo was migrated.

Fix: Never force-push to branches that produce published packages. If you must, republish symbols after the force push. Also verify the repository name in your Source Link configuration matches the Azure DevOps repository name exactly.

3. PublishSymbols Task Timeout

Error in pipeline:

##[error]Publishing symbols timed out. Elapsed time: 600.12 seconds.
The task was cancelled because the maximum wait time of 10 minutes was exceeded.

Cause: You are publishing a very large number of PDB files, or the symbol server is under heavy load.

Fix: Increase the timeout and narrow your search pattern:

- task: PublishSymbols@2
  inputs:
    SearchPattern: '**/bin/Release/**/MyCompany.*.pdb'   # Only your assemblies
    SymbolsMaximumWaitTime: 30   # 30 minutes
    SymbolServerType: 'TeamServices'

Also exclude test project PDBs — they do not need to be on the symbol server:

SearchPattern: |
  **/bin/Release/**/*.pdb
  !**/*Tests*/**/*.pdb
  !**/*Test*/**/*.pdb

4. Authentication Failure on Symbol Download

Error in Visual Studio Output window:

Unable to download symbols for MyLibrary.dll.
  HTTPS error: 401 Unauthorized
  Symbol server: https://pkgs.dev.azure.com/myorg/myproject/_packaging/internal-packages/symsrv/

Cause: Your Visual Studio session is not authenticated to Azure DevOps, or your PAT has expired.

Fix: Sign into Azure DevOps from Visual Studio via Tools > Options > Azure Service Authentication. If using a PAT, regenerate it with the Packaging (Read) scope. For automated scenarios, set the VSS_NUGET_EXTERNAL_FEED_ENDPOINTS environment variable.

5. Portable PDB Not Recognized by Legacy Tools

Error with symchk or WinDbg:

SYMCHK: MyLibrary.pdb  FAILED  - Portable PDB format is not supported by this tool version

Cause: Older versions of Windows Debugging Tools do not understand the Portable PDB format.

Fix: Update to the latest Windows SDK debugging tools, or use the dotnet-symbol tool instead:

dotnet tool install --global dotnet-symbol
dotnet-symbol --symbols --output ./symbols ./MyLibrary.dll

Best Practices

  • Publish symbols from every CI build, not just releases. When a developer needs to debug a pre-release version, the symbols must already be available. The storage cost is negligible compared to the time saved.

  • Set symbol expiration policies. Use SymbolExpirationInDays in your pipeline to automatically clean up old symbols. 365 days is reasonable for release builds; 90 days for feature branch builds. This prevents your symbol server from growing unbounded.

  • Always use Portable PDB format. Unless you have a specific requirement for Windows PDB (e.g., native C++ interop or legacy WinDbg workflows), use <DebugType>portable</DebugType>. It is smaller, faster, and cross-platform.

  • Enable Source Link in every library project. Symbols without source are only half the story. Source Link closes the loop by letting the debugger download the exact source file from your repository. Add it to your Directory.Build.props so every project inherits it automatically.

  • Configure a local symbol cache. Every developer workstation should have a local symbol cache directory (e.g., C:\SymbolCache or ~/.symbols). Without a cache, the debugger downloads PDBs from the network on every debug session, which is slow and wasteful.

  • Exclude test assemblies from symbol publication. Test projects generate PDBs too, but nobody is going to debug into MyLibrary.Tests.dll from a production dump. Exclude them to reduce publication time and storage.

  • Use ContinuousIntegrationBuild conditionally. Set <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild> only in CI builds. During local development, you want actual file paths in the PDB so that the debugger can find your local source. Use a condition: <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>.

  • Restrict the symbol search pattern. The PublishSymbols@2 task's SearchPattern should only match your own assemblies, not third-party PDBs that happened to be copied to the output directory. Use a pattern like **/MyCompany.*.pdb instead of **/*.pdb.

  • Monitor symbol server usage. Azure Artifacts provides consumption metrics. Keep an eye on storage usage and request rates. If your symbol server is getting thousands of requests per hour, consider whether your CI is configured to cache symbols properly.

References

Powered by Contentful