Skip to main content

haste_health/
main.rs

1use std::{
2    collections::HashMap,
3    path::PathBuf,
4    sync::{Arc, LazyLock},
5    time::Duration,
6};
7
8use clap::{Parser, Subcommand};
9use haste_config::{Config, ConfigType, get_config};
10use haste_fhir_model::r4::generated::terminology::IssueType;
11use haste_fhir_operation_error::OperationOutcomeError;
12use haste_server::auth_n::oidc::routes::discovery::WellKnownDiscoveryDocument;
13use opentelemetry::KeyValue;
14use opentelemetry::trace::TracerProvider as _;
15use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
16use opentelemetry_otlp::WithExportConfig;
17use opentelemetry_otlp::{Protocol, WithHttpConfig};
18use opentelemetry_sdk::Resource;
19use opentelemetry_sdk::logs::SdkLoggerProvider;
20use opentelemetry_sdk::trace::SdkTracerProvider;
21use reqwest::Url;
22use tokio::sync::Mutex;
23use tracing_subscriber::registry::LookupSpan;
24use tracing_subscriber::{EnvFilter, layer::SubscriberExt, registry::Registry};
25use tracing_tree::HierarchicalLayer;
26
27use crate::commands::config::{CLIConfiguration, load_config};
28
29mod client;
30mod commands;
31
32#[derive(Parser)]
33#[command(version, about, long_about = None)] // Read from `Cargo.toml`
34pub struct Cli {
35    #[command(subcommand)]
36    command: CLICommand,
37}
38
39#[derive(Subcommand)]
40enum CLICommand {
41    /// Data gets pulled from stdin.
42    FHIRPath {
43        /// FHIRPath expression to evaluate
44        fhirpath: String,
45    },
46    Generate {
47        /// Input FHIR StructureDefinition file (JSON)
48        #[command(subcommand)]
49        command: commands::codegen::CodeGen,
50    },
51    Server {
52        #[command(subcommand)]
53        command: commands::server::ServerCommands,
54    },
55    Api {
56        #[command(subcommand)]
57        command: commands::api::ApiCommands,
58    },
59    Config {
60        #[command(subcommand)]
61        command: commands::config::ConfigCommands,
62    },
63    Worker {
64        #[command(subcommand)]
65        command: Option<commands::worker::WorkerCommands>,
66    },
67    Testscript {
68        #[command(subcommand)]
69        command: commands::testscript::TestScriptCommands,
70    },
71    Admin {
72        #[command(subcommand)]
73        command: commands::admin::AdminCommands,
74    },
75    Hl7v2 {
76        #[command(subcommand)]
77        command: commands::hl7v2::HL7v2Commands,
78    },
79    Doc {
80        /// Output markdown file path
81        #[arg(short, long)]
82        output: String,
83    },
84}
85
86static CONFIG_LOCATION: LazyLock<PathBuf> = LazyLock::new(|| {
87    let config_dir = std::env::home_dir()
88        .unwrap_or_else(|| std::path::PathBuf::from("."))
89        .join(".haste_health");
90
91    std::fs::create_dir_all(&config_dir).expect("Failed to create config directory");
92
93    config_dir.join("config.toml")
94});
95
96struct CLIState {
97    config: CLIConfiguration,
98    access_token: Option<String>,
99    well_known_document: Option<WellKnownDiscoveryDocument>,
100}
101
102impl CLIState {
103    fn new(config: CLIConfiguration) -> Self {
104        CLIState {
105            config,
106            access_token: None,
107            well_known_document: None,
108        }
109    }
110}
111
112static CLI_STATE: LazyLock<Arc<Mutex<CLIState>>> = LazyLock::new(|| {
113    let config = load_config(&CONFIG_LOCATION);
114
115    Arc::new(Mutex::new(CLIState::new(config)))
116});
117
118enum CLIEnvironmentVariables {
119    SentryDSN,
120    OTELEndpoint,
121    OTELHeaders,
122    LogType,
123}
124
125impl From<CLIEnvironmentVariables> for String {
126    fn from(value: CLIEnvironmentVariables) -> Self {
127        match value {
128            CLIEnvironmentVariables::SentryDSN => "SENTRY_DSN".to_string(),
129            CLIEnvironmentVariables::OTELEndpoint => "OTEL_ENDPOINT".to_string(),
130            CLIEnvironmentVariables::OTELHeaders => "OTEL_HEADERS".to_string(),
131            CLIEnvironmentVariables::LogType => "LOG_TYPE".to_string(),
132        }
133    }
134}
135
136struct OtelGuard {
137    _tracer_provider: SdkTracerProvider,
138    _logger_provider: SdkLoggerProvider,
139}
140
141fn otel_guard(config: &dyn Config<CLIEnvironmentVariables>) -> Option<OtelGuard> {
142    let endpoint = config.get(CLIEnvironmentVariables::OTELEndpoint).ok()?;
143    let headers_str = config
144        .get(CLIEnvironmentVariables::OTELHeaders)
145        .unwrap_or_default();
146    let headers = headers_str
147        .split(',')
148        .filter_map(|pair| {
149            let mut parts = pair.splitn(2, '=');
150            if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
151                Some((key.trim().to_string(), value.trim().to_string()))
152            } else {
153                None
154            }
155        })
156        .collect::<HashMap<String, String>>();
157
158    let root_otel_endpoint = Url::parse(&endpoint).expect("Invalid OTLP endpoint URL");
159
160    // See https://opentelemetry.io/docs/specs/otlp/#otlphttp-request
161    // v1/traces for spans, v1/logs for logs
162    let mut trace_endpoint = root_otel_endpoint.clone();
163    trace_endpoint
164        .path_segments_mut()
165        .expect("OTEL endpoint cannot be a base URL")
166        .extend(&["v1", "traces"]);
167
168    let mut log_endpoint = root_otel_endpoint.clone();
169    log_endpoint
170        .path_segments_mut()
171        .expect("OTEL endpoint cannot be a base URL")
172        .extend(&["v1", "logs"]);
173
174    let oltp_span_exporter = opentelemetry_otlp::SpanExporter::builder()
175        .with_http()
176        .with_protocol(Protocol::HttpBinary)
177        .with_endpoint(trace_endpoint.as_str())
178        .with_headers(headers.clone())
179        .with_timeout(Duration::from_secs(5))
180        .build()
181        .expect("Failed to create OpenTelemetry span exporter");
182
183    let oltp_log_exporter = opentelemetry_otlp::LogExporter::builder()
184        .with_http()
185        .with_protocol(Protocol::HttpBinary)
186        .with_endpoint(log_endpoint.as_str())
187        .with_headers(headers)
188        .with_timeout(Duration::from_secs(5))
189        .build()
190        .expect("Failed to create OpenTelemetry log exporter");
191
192    let resource = Resource::builder()
193        .with_attribute(KeyValue::new("service.name", "haste-health"))
194        .build();
195
196    let tracer_provider = SdkTracerProvider::builder()
197        .with_resource(resource.clone())
198        .with_batch_exporter(oltp_span_exporter)
199        .build();
200
201    let logger_provider = SdkLoggerProvider::builder()
202        .with_resource(resource)
203        .with_batch_exporter(oltp_log_exporter)
204        .build();
205
206    opentelemetry::global::set_tracer_provider(tracer_provider.clone());
207
208    Some(OtelGuard {
209        _tracer_provider: tracer_provider,
210        _logger_provider: logger_provider,
211    })
212}
213
214fn inject_otel_subscriber<S>(
215    subscriber: S,
216    config: &dyn Config<CLIEnvironmentVariables>,
217) -> Option<OtelGuard>
218where
219    S: tracing::Subscriber + Send + Sync + 'static,
220    for<'span> S: LookupSpan<'span>,
221{
222    if let Some(guard) = otel_guard(config) {
223        let tracer = guard._tracer_provider.tracer("haste-health");
224        let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
225        let otel_logs = OpenTelemetryTracingBridge::new(&guard._logger_provider);
226
227        tracing::subscriber::set_global_default(subscriber.with(telemetry).with(otel_logs))
228            .unwrap();
229
230        Some(guard)
231    } else {
232        tracing::subscriber::set_global_default(subscriber).unwrap();
233        None
234    }
235}
236
237fn setup_tracing(
238    config: &dyn Config<CLIEnvironmentVariables>,
239) -> Result<Option<OtelGuard>, OperationOutcomeError> {
240    let log_type = config
241        .get(CLIEnvironmentVariables::LogType)
242        .unwrap_or("JSON".to_string());
243
244    let subscriber = Registry::default()
245        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")));
246
247    match log_type.as_str() {
248        "TREE" => Ok(inject_otel_subscriber(
249            subscriber.with(HierarchicalLayer::new(2)),
250            config,
251        )),
252        "JSON" => Ok(inject_otel_subscriber(
253            subscriber.with(tracing_subscriber::fmt::Layer::default().json()),
254            config,
255        )),
256        _ => Err(OperationOutcomeError::fatal(
257            IssueType::invalid(),
258            "Invalid log type specified in environment variable LOG_TYPE. Supported values are 'TREE' and 'JSON'.".to_string(),
259        )),
260    }
261}
262
263fn main() -> Result<(), OperationOutcomeError> {
264    let config = get_config(ConfigType::Environment);
265
266    let cli = Cli::parse();
267    let cli_state = CLI_STATE.clone();
268
269    let sentry_location = config.get(CLIEnvironmentVariables::SentryDSN);
270
271    let _guard = sentry::init((
272        sentry_location.unwrap_or_default(),
273        sentry::ClientOptions {
274            release: sentry::release_name!(),
275            // Capture user IPs and potentially sensitive headers when using HTTP server integrations
276            // see https://docs.sentry.io/platforms/rust/data-management/data-collected for more info
277            send_default_pii: true,
278            ..Default::default()
279        },
280    ));
281
282    tokio::runtime::Builder::new_multi_thread()
283        .enable_all()
284        // 8MB stack size
285        .thread_stack_size(1024 * 8000)
286        .build()
287        .unwrap()
288        .block_on(async {
289            let _otel_provider = setup_tracing(config.as_ref())?;
290            match &cli.command {
291                CLICommand::Doc { output } => commands::doc::generate_cli_markdown(output).await,
292                CLICommand::FHIRPath { fhirpath } => commands::fhirpath::fhirpath(fhirpath).await,
293                CLICommand::Generate { command } => commands::codegen::codegen(command).await,
294                CLICommand::Server { command } => commands::server::server(command).await,
295                CLICommand::Worker { command } => commands::worker::worker(command).await,
296                CLICommand::Config { command } => {
297                    commands::config::config(&cli_state, command).await
298                }
299                CLICommand::Api { command } => {
300                    commands::api::api_commands(cli_state, command).await
301                }
302                CLICommand::Testscript { command } => {
303                    commands::testscript::testscript_commands(cli_state, command).await
304                }
305                CLICommand::Admin { command } => commands::admin::admin(command).await,
306                CLICommand::Hl7v2 { command } => commands::hl7v2::hl7v2(cli_state, command).await,
307            }
308        })
309}