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
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ Use `--pretty` for indented JSON and `--raw` to print the Datadog response witho

```sh
ddcli logs search --query 'service:web error' --from now-15m --to now --limit 50
ddcli logs indexes order
ddcli logs indexes list --query production
ddcli logs indexes get bandzoogle-production
ddcli logs indexes patch-exclusions patch.json --dry-run
ddcli logs indexes patch-exclusions patch.json
ddcli logs pipelines order
ddcli logs pipelines list --query openresty
ddcli logs pipelines get PIPELINE_ID
ddcli synthetics list --query checkout --limit 25
ddcli synthetics get abc-def-ghi
ddcli metrics list --query system.cpu
Expand Down Expand Up @@ -81,6 +89,57 @@ ddcli scopes --command cost

Time flags accept `now`, relative values like `now-15m`, RFC3339 timestamps, Unix seconds, or Unix milliseconds. Logs and spans pass time strings through to Datadog; metrics and Error Tracking convert them to the epoch formats required by their APIs.

## Log configuration audit

The `logs indexes` and `logs pipelines` commands are read-only. Index responses
include routing filters, daily quotas, Standard/Flex retention, and ordered
exclusion filters with sample rates. Pipeline responses preserve the API's
processor order and include filters, parser rules, remappers, and nested
processors.

Use the explicit order commands when routing order matters:

```sh
ddcli logs indexes order --pretty
ddcli logs indexes get bandzoogle-production --pretty
ddcli logs pipelines order --pretty
ddcli logs pipelines list --query openresty --pretty
```

These commands require `logs_read_config`. Datadog also requires an
administrator-owned application key for pipeline configuration reads.

### Preconditioned exclusion patch

`logs indexes patch-exclusions` is the only log-configuration write command. It
fetches the named index, requires every named exclusion to occur exactly once
with the exact expected query, replaces only those query strings, and sends one
index update request containing all current updateable properties. Missing,
duplicate, stale, unknown, empty, and no-op patch entries fail without writing.

Patch files are non-secret JSON:

```json
{
"index_name": "example-index",
"replacements": [
{
"name": "Example exclusion",
"expected_query": "service:example status:info",
"replacement_query": "service:example status:(info OR ok)"
}
]
}
```

Always run `--dry-run` first. It performs the read and all precondition checks,
then prints the exact before/after queries and preserved invariants without an
update. The guarded read requires the `logs_read_config` RBAC permission.
Applying through the V1 `UpdateLogsIndex` endpoint requires
`logs_modify_indexes`, shown as **Logs Modify Indexes** in the Datadog UI.
These are role or scoped application-key permissions; Datadog does not offer
them as OAuth client scopes.

## Dashboard apply

`dashboards apply` validates a canonical Datadog dashboard JSON file and creates
Expand All @@ -100,7 +159,10 @@ matching also needs `dashboards_read`.

## Required Permissions

Use `ddcli scopes` to print the Datadog permissions and OAuth scopes needed by each command group. This command does not require Datadog credentials.
Use `ddcli scopes` to print the Datadog RBAC permissions and, where available,
separate OAuth scopes needed by each command group. Commands marked with no
OAuth scope require application-key authentication. This command does not
require Datadog credentials.

Datadog API keys identify the organization. Access is controlled by the application key owner's role permissions, scoped application key permissions, or OAuth access token scopes.

Expand Down
208 changes: 206 additions & 2 deletions cmd/logs.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
package cmd

import (
"strings"

"github.com/DataDog/datadog-api-client-go/v2/api/datadog"
"github.com/DataDog/datadog-api-client-go/v2/api/datadogV1"
"github.com/DataDog/datadog-api-client-go/v2/api/datadogV2"
"github.com/bandzoogle/datadog-cli/internal/output"
"github.com/spf13/cobra"
)

var logsCmd = &cobra.Command{
Use: "logs",
Short: "Search Datadog logs",
Short: "Search logs and manage log configuration",
}

var logsSearchCmd = &cobra.Command{
Expand All @@ -23,16 +26,194 @@ var logsSearchCmd = &cobra.Command{
RunE: runLogsSearch,
}

var logsIndexesCmd = &cobra.Command{
Use: "indexes",
Short: "Inspect log indexes and exclusion filters",
}

var logsIndexesListCmd = &cobra.Command{
Use: "list",
Short: "List log indexes in evaluation order",
RunE: runLogsIndexesList,
}

var logsIndexesGetCmd = &cobra.Command{
Use: "get <index-name>",
Short: "Get a log index including exclusion filters",
Args: cobra.ExactArgs(1),
RunE: runLogsIndexesGet,
}

var logsIndexesOrderCmd = &cobra.Command{
Use: "order",
Short: "Get authoritative log index routing order",
RunE: runLogsIndexesOrder,
}

var logsPipelinesCmd = &cobra.Command{
Use: "pipelines",
Short: "Inspect log processing pipelines",
}

var logsPipelinesListCmd = &cobra.Command{
Use: "list",
Short: "List log pipelines in evaluation order",
RunE: runLogsPipelinesList,
}

var logsPipelinesGetCmd = &cobra.Command{
Use: "get <pipeline-id>",
Short: "Get a log pipeline including processors",
Args: cobra.ExactArgs(1),
RunE: runLogsPipelinesGet,
}

var logsPipelinesOrderCmd = &cobra.Command{
Use: "order",
Short: "Get authoritative log pipeline order",
RunE: runLogsPipelinesOrder,
}

func init() {
rootCmd.AddCommand(logsCmd)
logsCmd.AddCommand(logsSearchCmd)
logsCmd.AddCommand(logsSearchCmd, logsIndexesCmd, logsPipelinesCmd)
logsIndexesCmd.AddCommand(logsIndexesListCmd, logsIndexesGetCmd, logsIndexesOrderCmd)
logsPipelinesCmd.AddCommand(logsPipelinesListCmd, logsPipelinesGetCmd, logsPipelinesOrderCmd)

logsSearchCmd.Flags().String("query", "", "Log search query")
logsSearchCmd.Flags().String("from", "now-15m", "Start time, e.g. now-15m or RFC3339")
logsSearchCmd.Flags().String("to", "now", "End time, e.g. now or RFC3339")
logsSearchCmd.Flags().Int32("limit", 50, "Maximum logs to return")
logsSearchCmd.Flags().String("cursor", "", "Pagination cursor from a previous response")
logsSearchCmd.Flags().StringSlice("index", nil, "Log indexes to search; repeat or comma-separate")
logsIndexesListCmd.Flags().String("query", "", "Client-side filter for index name")
logsPipelinesListCmd.Flags().String("query", "", "Client-side filter for pipeline name or ID")
}

func runLogsIndexesList(cmd *cobra.Command, args []string) error {
client, err := datadogClient(cmd)
if err != nil {
return err
}
query, _ := cmd.Flags().GetString("query")
api := datadogV1.NewLogsIndexesApi(client.API)
indexes, response, err := api.ListLogIndexes(client.Context)
if err != nil {
return apiError("logs indexes list", response, err)
}
if query != "" {
indexes.Indexes = filterLogIndexes(indexes.Indexes, query)
}
order, orderResponse, err := api.GetLogsIndexOrder(client.Context)
if err != nil {
return apiError("logs indexes order", orderResponse, err)
}
return output.WriteEnvelope(cmd.OutOrStdout(),
map[string]any{"command": "logs indexes list", "filter": query},
meta(client.Site, map[string]any{"count": len(indexes.Indexes)}, response),
map[string]any{"indexes": indexes, "order": order},
outputOptions(),
)
}

func runLogsIndexesGet(cmd *cobra.Command, args []string) error {
client, err := datadogClient(cmd)
if err != nil {
return err
}
name := args[0]
api := datadogV1.NewLogsIndexesApi(client.API)
index, response, err := api.GetLogsIndex(client.Context, name)
if err != nil {
return apiError("logs indexes get", response, err)
}
return output.WriteEnvelope(cmd.OutOrStdout(),
map[string]any{"command": "logs indexes get", "index_name": name},
meta(client.Site, nil, response),
index,
outputOptions(),
)
}

func runLogsIndexesOrder(cmd *cobra.Command, args []string) error {
client, err := datadogClient(cmd)
if err != nil {
return err
}
api := datadogV1.NewLogsIndexesApi(client.API)
order, response, err := api.GetLogsIndexOrder(client.Context)
if err != nil {
return apiError("logs indexes order", response, err)
}
return output.WriteEnvelope(cmd.OutOrStdout(),
map[string]any{"command": "logs indexes order"},
meta(client.Site, nil, response),
order,
outputOptions(),
)
}

func runLogsPipelinesList(cmd *cobra.Command, args []string) error {
client, err := datadogClient(cmd)
if err != nil {
return err
}
query, _ := cmd.Flags().GetString("query")
api := datadogV1.NewLogsPipelinesApi(client.API)
pipelines, response, err := api.ListLogsPipelines(client.Context)
if err != nil {
return apiError("logs pipelines list", response, err)
}
if query != "" {
pipelines = filterLogPipelines(pipelines, query)
}
order, orderResponse, err := api.GetLogsPipelineOrder(client.Context)
if err != nil {
return apiError("logs pipelines order", orderResponse, err)
}
return output.WriteEnvelope(cmd.OutOrStdout(),
map[string]any{"command": "logs pipelines list", "filter": query},
meta(client.Site, map[string]any{"count": len(pipelines)}, response),
map[string]any{"pipelines": pipelines, "order": order},
outputOptions(),
)
}

func runLogsPipelinesGet(cmd *cobra.Command, args []string) error {
client, err := datadogClient(cmd)
if err != nil {
return err
}
id := args[0]
api := datadogV1.NewLogsPipelinesApi(client.API)
pipeline, response, err := api.GetLogsPipeline(client.Context, id)
if err != nil {
return apiError("logs pipelines get", response, err)
}
return output.WriteEnvelope(cmd.OutOrStdout(),
map[string]any{"command": "logs pipelines get", "pipeline_id": id},
meta(client.Site, nil, response),
pipeline,
outputOptions(),
)
}

func runLogsPipelinesOrder(cmd *cobra.Command, args []string) error {
client, err := datadogClient(cmd)
if err != nil {
return err
}
api := datadogV1.NewLogsPipelinesApi(client.API)
order, response, err := api.GetLogsPipelineOrder(client.Context)
if err != nil {
return apiError("logs pipelines order", response, err)
}
return output.WriteEnvelope(cmd.OutOrStdout(),
map[string]any{"command": "logs pipelines order"},
meta(client.Site, nil, response),
order,
outputOptions(),
)
}

func runLogsSearch(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -83,3 +264,26 @@ func runLogsSearch(cmd *cobra.Command, args []string) error {
outputOptions(),
)
}

func filterLogIndexes(indexes []datadogV1.LogsIndex, query string) []datadogV1.LogsIndex {
query = strings.ToLower(strings.TrimSpace(query))
filtered := make([]datadogV1.LogsIndex, 0, len(indexes))
for _, index := range indexes {
if strings.Contains(strings.ToLower(index.GetName()), query) {
filtered = append(filtered, index)
}
}
return filtered
}

func filterLogPipelines(pipelines []datadogV1.LogsPipeline, query string) []datadogV1.LogsPipeline {
query = strings.ToLower(strings.TrimSpace(query))
filtered := make([]datadogV1.LogsPipeline, 0, len(pipelines))
for _, pipeline := range pipelines {
if strings.Contains(strings.ToLower(pipeline.GetName()), query) ||
strings.Contains(strings.ToLower(pipeline.GetId()), query) {
filtered = append(filtered, pipeline)
}
}
return filtered
}
53 changes: 53 additions & 0 deletions cmd/logs_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package cmd

import (
"testing"

"github.com/DataDog/datadog-api-client-go/v2/api/datadogV1"
)

func TestFilterLogIndexesPreservesOrder(t *testing.T) {
indexes := []datadogV1.LogsIndex{
logIndex("bandzoogle-production"),
logIndex("bandzoogle-development"),
logIndex("other"),
}

got := filterLogIndexes(indexes, "bandzoogle")

if len(got) != 2 {
t.Fatalf("expected two indexes, got %d", len(got))
}
if got[0].GetName() != "bandzoogle-production" ||
got[1].GetName() != "bandzoogle-development" {
t.Fatalf("unexpected filtered order: %#v", got)
}
}

func TestFilterLogPipelinesMatchesNameOrID(t *testing.T) {
pipelines := []datadogV1.LogsPipeline{
logPipeline("abc-123", "OpenResty"),
logPipeline("def-456", "Rails"),
}

byName := filterLogPipelines(pipelines, "openresty")
if len(byName) != 1 || byName[0].GetId() != "abc-123" {
t.Fatalf("unexpected name match: %#v", byName)
}

byID := filterLogPipelines(pipelines, "DEF")
if len(byID) != 1 || byID[0].GetName() != "Rails" {
t.Fatalf("unexpected ID match: %#v", byID)
}
}

func logIndex(name string) datadogV1.LogsIndex {
index := datadogV1.NewLogsIndex(*datadogV1.NewLogsFilter(), name)
return *index
}

func logPipeline(id, name string) datadogV1.LogsPipeline {
pipeline := datadogV1.NewLogsPipeline(name)
pipeline.SetId(id)
return *pipeline
}
Loading
Loading