logging: Introduce types for structured UI messages + `UnmarshalLogMessage()` (#167)
diff --git a/logging.go b/logging.go new file mode 100644 index 0000000..adfcf60 --- /dev/null +++ b/logging.go
@@ -0,0 +1,84 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package tfjson + +import ( + "bytes" + "encoding/json" + "time" +) + +// LogMessageLevel represents log level +// See https://github.com/hashicorp/go-hclog/blob/v1.6.3/logger.go#L126-L145 +type LogMessageLevel string + +const ( + // Trace is the most verbose level. Intended to be used for the tracing + // of actions in code, such as function enters/exits, etc. + Trace LogMessageLevel = "trace" + + // Debug information for programmer low-level analysis. + Debug LogMessageLevel = "debug" + + // Info information about steady state operations. + Info LogMessageLevel = "info" + + // Warn information about rare but handled events. + Warn LogMessageLevel = "warn" + + // Error information about unrecoverable events. + Error LogMessageLevel = "error" +) + +// LogMessage represents a log message emitted from commands +// which support structured log output. +// +// This is implemented via hashicorp/go-hclog which +// defines the format. +type LogMsg interface { + Level() LogMessageLevel + Message() string + Timestamp() time.Time +} + +type baseLogMessage struct { + Lvl LogMessageLevel `json:"@level"` + Msg string `json:"@message"` + Time time.Time `json:"@timestamp"` +} + +type msgType struct { + // Type represents a message type + // which is documented at https://developer.hashicorp.com/terraform/internals/machine-readable-ui#message-types + Type LogMessageType `json:"type"` +} + +func (m baseLogMessage) Level() LogMessageLevel { + return m.Lvl +} + +func (m baseLogMessage) Message() string { + return m.Msg +} + +func (m baseLogMessage) Timestamp() time.Time { + return m.Time +} + +// UnknownLogMessage represents a message of unknown type +type UnknownLogMessage struct { + baseLogMessage +} + +func UnmarshalLogMessage(b []byte) (LogMsg, error) { + d := json.NewDecoder(bytes.NewReader(b)) + + mt := msgType{} + err := d.Decode(&mt) + if err != nil { + return nil, err + } + + v, err := unmarshalByType(mt.Type, b) + return v, err +}
diff --git a/logging_generic.go b/logging_generic.go new file mode 100644 index 0000000..4414abc --- /dev/null +++ b/logging_generic.go
@@ -0,0 +1,27 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package tfjson + +import "github.com/hashicorp/go-version" + +// VersionLogMessage represents information about the Terraform version +// and the version of the schema used for the following messages. +// This is a message of type "version". +type VersionLogMessage struct { + baseLogMessage + Terraform *version.Version `json:"terraform"` + UI *version.Version `json:"ui"` +} + +// LogMessage represents a generic human-readable log line +// This is a message of type "log" +type LogMessage struct { + baseLogMessage +} + +// DiagnosticLogMessage represents diagnostic warning or error message. +// This is a message of type "diagnostic" +type DiagnosticLogMessage struct { + baseLogMessage + Diagnostic `json:"diagnostic"` +}
diff --git a/logging_test.go b/logging_test.go new file mode 100644 index 0000000..851cc2c --- /dev/null +++ b/logging_test.go
@@ -0,0 +1,99 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package tfjson + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-version" +) + +var cmpOpts = cmp.AllowUnexported(allLogMessageTypes...) + +func TestLogging_generic(t *testing.T) { + testCases := []struct { + rawMessage string + expectedMessage LogMsg + }{ + { + `{"@level":"info","@message":"Installing provider version: hashicorp/aws v6.8.0...","@module":"terraform.ui","@timestamp":"2025-08-11T15:09:18.827459+00:00","type":"log"}`, + LogMessage{ + baseLogMessage: baseLogMessage{ + Lvl: Info, + Msg: "Installing provider version: hashicorp/aws v6.8.0...", + Time: time.Date(2025, 8, 11, 15, 9, 18, 827459000, time.UTC), + }, + }, + }, + { + `{"@level":"info","@message":"Terraform 1.9.0","@module":"terraform.ui","@timestamp":"2025-08-11T15:09:15.919212+00:00","terraform":"1.9.0","type":"version","ui":"1.2"}`, + VersionLogMessage{ + baseLogMessage: baseLogMessage{ + Lvl: Info, + Msg: "Terraform 1.9.0", + Time: time.Date(2025, 8, 11, 15, 9, 15, 919212000, time.UTC), + }, + Terraform: version.Must(version.NewVersion("1.9.0")), + UI: version.Must(version.NewVersion("1.2")), + }, + }, + { + `{"@level":"error","@message":"Error: Unclosed configuration block","@module":"terraform.ui","@timestamp":"2025-08-13T10:40:46.749685+00:00","diagnostic":{"severity":"error","summary":"Unclosed configuration block","detail":"There is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.","range":{"filename":"main.tf","start":{"line":11,"column":30,"byte":153},"end":{"line":11,"column":31,"byte":154}},"snippet":{"context":"resource \"random_pet\" \"name\"","code":"resource \"random_pet\" \"name\" {","start_line":11,"highlight_start_offset":29,"highlight_end_offset":30,"values":[]}},"type":"diagnostic"}`, + DiagnosticLogMessage{ + baseLogMessage: baseLogMessage{ + Lvl: Error, + Msg: "Error: Unclosed configuration block", + Time: time.Date(2025, 8, 13, 10, 40, 46, 749685000, time.UTC), + }, + Diagnostic: Diagnostic{ + Severity: DiagnosticSeverityError, + Summary: "Unclosed configuration block", + Detail: "There is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.", + Range: &Range{ + Filename: "main.tf", + Start: Pos{ + Line: 11, + Column: 30, + Byte: 153, + }, + End: Pos{ + Line: 11, + Column: 31, + Byte: 154, + }, + }, + Snippet: &DiagnosticSnippet{ + Context: ptrToString(`resource "random_pet" "name"`), + Code: `resource "random_pet" "name" {`, + StartLine: 11, + HighlightStartOffset: 29, + HighlightEndOffset: 30, + Values: []DiagnosticExpressionValue{}, + }, + }, + }, + }, + { + `{"@level":"debug","@message":"Foobar","@module":"terraform.ui","@timestamp":"2025-08-11T15:09:18.827459+00:00","type":"FOO"}`, + UnknownLogMessage{ + baseLogMessage: baseLogMessage{ + Lvl: Debug, + Msg: "Foobar", + Time: time.Date(2025, 8, 11, 15, 9, 18, 827459000, time.UTC), + }, + }, + }, + } + + for _, tc := range testCases { + msg, err := UnmarshalLogMessage([]byte(tc.rawMessage)) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(tc.expectedMessage, msg, cmpOpts); diff != "" { + t.Fatalf("unexpected message: %s", diff) + } + } +}
diff --git a/logging_types.go b/logging_types.go new file mode 100644 index 0000000..e26522a --- /dev/null +++ b/logging_types.go
@@ -0,0 +1,41 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package tfjson + +import ( + "encoding/json" +) + +type LogMessageType string + +const ( + MessageTypeVersion LogMessageType = "version" + MessageTypeLog LogMessageType = "log" + MessageTypeDiagnostic LogMessageType = "diagnostic" +) + +// allLogMessageTypes is a slice containing all recognised message types +// to be passed into cmp.AllowUnexported +var allLogMessageTypes = []any{ + VersionLogMessage{}, + LogMessage{}, + DiagnosticLogMessage{}, + UnknownLogMessage{}, +} + +func unmarshalByType(t LogMessageType, b []byte) (LogMsg, error) { + switch t { + case MessageTypeVersion: + v := VersionLogMessage{} + return v, json.Unmarshal(b, &v) + case MessageTypeLog: + v := LogMessage{} + return v, json.Unmarshal(b, &v) + case MessageTypeDiagnostic: + v := DiagnosticLogMessage{} + return v, json.Unmarshal(b, &v) + } + + v := UnknownLogMessage{} + return v, json.Unmarshal(b, &v) +}